Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87fd8f364 | ||
|
|
8122129a19 | ||
|
|
93a4ece886 | ||
|
|
d1976499a2 | ||
|
|
0faf3d63ee | ||
|
|
3aa0d33a01 | ||
|
|
cd067ad584 | ||
|
|
66a39b2291 | ||
|
|
2083d2ff6a | ||
|
|
aa83505997 | ||
|
|
65f3615661 | ||
|
|
5b18db3a77 | ||
|
|
9ba00045c9 | ||
|
|
b6cf46b883 | ||
|
|
e1031b3ecc | ||
|
|
4960e26d8c | ||
|
|
3cb0ac9236 | ||
|
|
9bdb142090 | ||
|
|
c78bf1a851 | ||
|
|
c3e2e05e89 | ||
|
|
e786580d42 | ||
|
|
6efb498409 | ||
|
|
ae80922d03 | ||
|
|
23dd283a42 | ||
|
|
8dc4d85e14 | ||
|
|
2ffbf0f66c | ||
|
|
d3b5fbb1b8 | ||
|
|
86b4d436fa | ||
|
|
92f952b136 | ||
|
|
1623b879e2 | ||
|
|
3510cc4260 | ||
|
|
9e17ba3479 |
@@ -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
|
||||||
@@ -13,13 +13,13 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: windows
|
runs-on: windows
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
|
||||||
|
|
||||||
- name: Install Rust
|
- 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
|
- name: Install Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
run: npm run app:build
|
run: npm run app:build
|
||||||
|
|
||||||
- name: Upload installer
|
- name: Upload installer
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
|
||||||
with:
|
with:
|
||||||
name: voiced-windows
|
name: voiced-windows
|
||||||
path: src-tauri/target/release/bundle/nsis/*.exe
|
path: src-tauri/target/release/bundle/nsis/*.exe
|
||||||
|
|||||||
@@ -31,3 +31,7 @@ xwin/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
handoff.md
|
handoff.md
|
||||||
.commandcode/
|
.commandcode/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|||||||
@@ -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/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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".
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -1,84 +1,213 @@
|
|||||||
# Voiced
|
# Voiced
|
||||||
|
|
||||||
An offline-first desktop invoicing 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, GST-ready PDF invoice. It keeps a running
|
Voiced turns a few details into a clean, print-ready PDF invoice. It keeps a running invoice-number series in a
|
||||||
invoice-number series in a local database, remembers your vendor and bank details, and
|
local database, remembers your vendor, client and bank details, and works without an account or a network
|
||||||
exports print-ready PDFs — no account, no network required.
|
connection. The only optional network feature is the ERPNext push, which you configure yourself.
|
||||||
|
|
||||||
|
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
|
## Features
|
||||||
|
|
||||||
- **Automatic invoice numbering** — a persistent series (e.g. `INV/2026-001`) that keeps
|
- **First-run setup.** A four-step wizard (business, branding, bank, numbering) starts blank: no vendor, bank or tax
|
||||||
counting up, with a one-click "start a new series" that resets the counter.
|
details are pre-filled, and a business name and state are required. **Settings, Data, Run setup again** reopens it
|
||||||
- **Guided first-run setup** — your name, address, contact, PAN, GSTIN, logo, signature and
|
with your current details; invoices, clients and backups are kept, and numbering carries on unless you change the
|
||||||
bank details, pre-filled from your existing invoice and editable at any time.
|
series prefix or digits.
|
||||||
- **Two line-item modes** — a fixed amount, or a rate × quantity where the unit can be per
|
- **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series,
|
||||||
second / minute / hour / session / unit (e.g. 10 sessions × ₹2,500).
|
drafts, issue and cancel. An issued invoice is immutable: it stores a snapshot of the vendor, client, GST figures
|
||||||
- **GST support** — toggle taxes on/off and split as **CGST + SGST** (within state) or
|
and layout. Line items are a fixed amount or rate x quantity in a unit of measure (time, distance, area, weight,
|
||||||
**IGST** (inter-state), with live totals and Indian amount-in-words.
|
volume, a count, or a custom unit; a time typed as `4:30` counts as 4.5 hours), with item presets.
|
||||||
- **Signatures** — attach a PNG/JPEG per invoice, or omit it and the invoice notes that it is
|
- **GST.** All money and tax maths is done in integer paise. CGST+SGST, IGST or UTGST follow from the supplier's
|
||||||
computer-generated and needs no signature.
|
state and the place of supply, and a mismatch is rejected. An unregistered vendor gets a plain "Invoice" with no
|
||||||
- **PDF export** — a print-ready A4 invoice styled to match the original `format.pdf`.
|
tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words.
|
||||||
- **Saved clients**, invoice history with re-export, and light/dark (Carbon g10/g100) themes.
|
- **Templates.** 14 templates: Voiced Standard (Classic) and 13 further designs (Linea,
|
||||||
- **Input validation** for PAN, IFSC, GSTIN, email and phone.
|
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/`.
|
||||||
|
- **Page setup.** A4 or Letter, and margin presets (template, narrow 36 pt, normal 54 pt).
|
||||||
|
- **Export modes.** *Searchable* PDF (text layer kept) or *flattened* (pages rasterised at 300 DPI and rebuilt as
|
||||||
|
an image-only PDF). Issued PDFs are archived by content hash, and "Original as issued" re-exports the exact
|
||||||
|
archived bytes.
|
||||||
|
- **Fonts.** 11 bundled font families (see "Fonts and licences"). The three commercial template faces (Now, Gotham,
|
||||||
|
Open Sauce One) can be overridden with your own licensed files under Settings, Fonts.
|
||||||
|
- **Clients and presets.** Saved clients with structured addresses, state and GST category; item presets.
|
||||||
|
- **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`).
|
||||||
|
- **ERPNext integration.** Push an invoice (as draft or submitted) with its PDF attached, and record payments, to an
|
||||||
|
ERPNext v15 site.
|
||||||
|
- Light and dark (Carbon g10/g100) themes.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
| Layer | Choice |
|
| Layer | Choice |
|
||||||
| --------- | ------------------------------------------------------------------- |
|
| ------- | ------------------------------------------------------------------------------------------- |
|
||||||
| Shell | [Tauri v2](https://v2.tauri.app/) (Rust core, WebView2 on Windows) |
|
| Shell | [Tauri v2](https://v2.tauri.app/) (Rust core, WebKitGTK on Linux, WebView2 on Windows) |
|
||||||
| UI | React + TypeScript + [IBM Carbon Design System](https://carbondesignsystem.com/) (`@carbon/react`) |
|
| UI | React 19 + TypeScript + [IBM Carbon](https://carbondesignsystem.com/) (`@carbon/react`) |
|
||||||
| Fonts | IBM Plex, self-hosted (works offline) |
|
| Storage | SQLite via `rusqlite` (bundled), numbered migrations |
|
||||||
| Storage | SQLite via `rusqlite` (bundled) |
|
| PDF | `@react-pdf/renderer` 4.9.0 in a Web Worker, pdf.js for preview and flattening |
|
||||||
| PDF | `@react-pdf/renderer` with bundled IBM Plex |
|
| ERPNext | `reqwest` with rustls (ring provider); all HTTP runs in Rust |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js 20+** and npm
|
- **Node.js 22** and npm
|
||||||
- **Rust** (stable) via [rustup](https://rustup.rs/)
|
- **Rust** stable, via [rustup](https://rustup.rs/)
|
||||||
- Linux dev only: WebKitGTK 4.1, libsoup3, GTK3, librsvg, `pkg-config`, `clang`/`llvm`/`lld`
|
- Linux only: WebKitGTK 4.1, libsoup3, GTK3, librsvg, `pkg-config`, `clang`/`llvm`/`lld`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run app:dev # launches the desktop app with hot reload
|
npm run app:dev # tauri dev, served from localhost:1420 (does not exercise the tauri:// protocol)
|
||||||
```
|
```
|
||||||
|
|
||||||
Other scripts:
|
| Command | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `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 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) |
|
||||||
|
|
||||||
```bash
|
The release procedure, including the manual first-run checklist, is in `docs/RELEASE.md`.
|
||||||
npm run build # type-check + build the web frontend
|
|
||||||
npm run app:build # package the app for the current platform
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building for Windows (cross-compile from Linux/macOS)
|
## Installing
|
||||||
|
|
||||||
Tauri only supports the MSVC target. Cross-compiling uses [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin)
|
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
|
||||||
and produces an **NSIS installer** plus a portable `.exe`. A `.msi` still requires a real
|
`v1.2.0`).
|
||||||
Windows host.
|
|
||||||
|
- **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:
|
||||||
|
|
||||||
|
| What | Linux | Windows |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `app_data_dir`: database `voiced.db`, `assets/` (logos, signatures), `backups/` (`auto/`, `pre-restore-*`, pre-migration copies) | `~/.local/share/com.bongbetic.voiced/` | `%APPDATA%\com.bongbetic.voiced\` |
|
||||||
|
| `app_local_data_dir`: `archive/<sha256>.pdf` (issued PDFs), `fonts/` (fonts you import) | `~/.local/share/com.bongbetic.voiced/` | `%LOCALAPPDATA%\com.bongbetic.voiced\` |
|
||||||
|
|
||||||
|
On Linux `XDG_DATA_HOME` moves both. On Windows the archive and imported fonts stay out of the roaming profile so a
|
||||||
|
growing archive and licensed font files are not copied between machines. The window size and position are kept by
|
||||||
|
the window-state plugin in the app config directory (`~/.config/com.bongbetic.voiced/` on Linux).
|
||||||
|
|
||||||
|
## Backup and restore
|
||||||
|
|
||||||
|
Settings, Data tab.
|
||||||
|
|
||||||
|
- **Backup now** writes a zip: a consistent snapshot of `voiced.db`, `assets/`, `archive/`, `fonts/` and a
|
||||||
|
`manifest.json` with a sha256 for every file.
|
||||||
|
- **Automatic backup.** Once per calendar day, while the app is open, Voiced writes
|
||||||
|
`backups/auto/voiced-auto-YYYYMMDD.zip` and keeps the newest 14.
|
||||||
|
- **Restore** validates the zip (manifest, hashes, database integrity check) and stages it. Nothing live changes until
|
||||||
|
the next start: use the "Restart" button or close and reopen the app. On that start the current database, assets,
|
||||||
|
archive and fonts are moved to `backups/pre-restore-<timestamp>/` (a safety copy that is never pruned), then the
|
||||||
|
restored files are moved in. If anything fails the app rolls back and starts on the old data. A backup from an
|
||||||
|
older schema is migrated after restoring.
|
||||||
|
- **Backups contain secrets.** The ERPNext API secret is stored in the SQLite database, so every backup (manual,
|
||||||
|
automatic and pre-restore) holds it in plain form, together with your clients, invoices and bank details. Keep
|
||||||
|
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,
|
||||||
|
IBM Plex Mono, IBM Plex Sans, Inter, Jost, Lustria, Montserrat, Noto Sans Devanagari, Noto Sans Kannada, Open Sans
|
||||||
|
and Poppins. All are under the SIL Open Font License 1.1. `public/fonts/manifest.json` lists every file with its
|
||||||
|
hash and metrics, and `npm run fonts:verify` checks it. PDFs embed subsets, so the app never depends on system
|
||||||
|
fonts.
|
||||||
|
|
||||||
|
The Void package also installs these fonts system-wide under `/usr/share/fonts/voiced/<family>/` and each OFL text
|
||||||
|
as `/usr/share/licenses/voiced/<family-folder>-OFL.txt`.
|
||||||
|
|
||||||
|
**Now, Gotham and Open Sauce One** are not bundled. Voiced draws Now with Jost, Gotham with Montserrat and Open
|
||||||
|
Sauce One with Poppins (Marble, Citrus Split and Purple Pop use these faces). If you hold a licence
|
||||||
|
for the real fonts, import the files under Settings, Fonts (TTF, OTF or WOFF, one file per weight; WOFF2, variable
|
||||||
|
and font-collection files are refused). Importing is your confirmation that your licence covers this use. The files
|
||||||
|
are stored on this computer only, are included in backups, and issued invoices keep the fonts they were issued with.
|
||||||
|
|
||||||
|
## ERPNext integration
|
||||||
|
|
||||||
|
Settings, Integrations. Verified live against ERPNext v15.121.6 (Frappe 15.121), with and without India Compliance
|
||||||
|
15.32. v14 and v16 have not been tested.
|
||||||
|
|
||||||
|
1. **Integration user.** In ERPNext create a dedicated user (type System User) with the roles **Accounts User** and
|
||||||
|
**Sales User**. Open the user, Settings tab, API Access, Generate Keys, and copy the secret once. Accounts User
|
||||||
|
covers invoices, payments and attachments; Sales User covers customers and addresses (Accounts User alone gets
|
||||||
|
HTTP 403 on Customer). Do not use Administrator or System Manager keys. If you change roles, run
|
||||||
|
`bench clear-cache`.
|
||||||
|
2. **Rounding.** In ERPNext System Settings set Rounding Method to **Commercial Rounding**. ERPNext defaults to
|
||||||
|
Banker's Rounding while Voiced rounds half-paise up, so some totals would differ by 1 paise. Voiced detects a
|
||||||
|
mismatch after creating the draft, keeps it as a draft, never submits it, and names this fix.
|
||||||
|
3. **Connect.** Enter the site address, API key and secret and run "Test connection". Use `https://` for real
|
||||||
|
servers; plain `http://` is accepted only for `localhost`, `127.0.0.1`, `*.localhost` and `*.test`. The secret is
|
||||||
|
never shown again, and it is not sent to a different server than the one it was saved for.
|
||||||
|
4. **Company, accounts and units.** Pick the company, the output tax accounts (CGST, SGST, IGST), the bank account for
|
||||||
|
payments and the unit-of-measure mapping from the loaded option lists.
|
||||||
|
5. **Naming.** *Mirror* (default) names the ERPNext document with the Voiced number, which needs a site with API v2
|
||||||
|
naming (Frappe 15.73 or newer). *Series* lets an ERPNext naming series name the document and puts the Voiced
|
||||||
|
number in the remarks.
|
||||||
|
6. **India Compliance.** Detected by the connection test. It requires a 6 or 8 digit HSN/SAC code on each row
|
||||||
|
(checked at submit), limits document names to 16 characters (mirror mode checks this before posting), and Voiced
|
||||||
|
always sends `is_reverse_charge` as 0 with a warning.
|
||||||
|
|
||||||
|
Not covered: overseas and SEZ customers, multi-currency, reverse charge booked on RCM accounts, e-invoice and
|
||||||
|
e-waybill, TLS-terminating proxies beyond plain HTTPS, and Windows. The live test setup is in
|
||||||
|
`scripts/erpnext-e2e/README.md`.
|
||||||
|
|
||||||
|
## Building for Windows (NSIS, cross-compiled from Linux)
|
||||||
|
|
||||||
|
Tauri supports only the MSVC target. [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin) cross-compiles it
|
||||||
|
and Tauri produces an **NSIS installer** (a `.msi` needs a real Windows host).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# one-time setup
|
# one-time setup
|
||||||
rustup target add x86_64-pc-windows-msvc
|
rustup target add x86_64-pc-windows-msvc
|
||||||
cargo install --locked cargo-xwin
|
cargo install --locked cargo-xwin
|
||||||
# plus NSIS (makensis), LLVM/LLD and clang on the host
|
# plus makensis (NSIS), LLVM/LLD and clang on the host
|
||||||
|
|
||||||
npm run app:build:windows
|
npm run app:build:windows # embeds the small WebView2 bootstrapper (needs internet if WebView2 is missing)
|
||||||
|
npm run app:build:windows:offline # embeds the full WebView2 runtime (over 100 MB larger), for offline machines
|
||||||
```
|
```
|
||||||
|
|
||||||
Output lands in `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/` and the
|
Output: `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/Voiced_<version>_x64-setup.exe`, and the portable
|
||||||
portable binary in the sibling `release/` folder.
|
binary in the sibling `release/` folder. The installer is a per-user install (`installMode: currentUser`). The
|
||||||
|
frontend, fonts and pdf.js are compiled into the executable. Size budget: about 25 MB for the standard installer.
|
||||||
|
`cargo tree -i aws-lc-sys --target x86_64-pc-windows-msvc` must print nothing (the TLS stack is rustls with the ring
|
||||||
|
provider only, which cross-compiles cleanly).
|
||||||
|
|
||||||
### Notes for Linux hosts without an `nsis` package
|
Hosts without an `nsis` package (for example Void Linux):
|
||||||
|
|
||||||
`makensis` is required for the installer, and Tauri also fetches a small NSIS plugin. On
|
1. Provide `makensis` and put it on `PATH`.
|
||||||
distributions that don't package NSIS (e.g. Void Linux):
|
2. Point it at a full NSIS data directory (3.11 recommended, for `Win/RestartManager.nsh`) with `NSISDIR`.
|
||||||
|
|
||||||
1. Provide `makensis` (e.g. extract it from your distribution's or an upstream package) and
|
|
||||||
put it on `PATH`.
|
|
||||||
2. Point `makensis` at a full NSIS data directory (3.11 recommended, for `Win/RestartManager.nsh`);
|
|
||||||
it looks in the directory compiled into the binary unless `NSISDIR` is set.
|
|
||||||
3. Pre-seed Tauri's plugin cache so it does not have to download it:
|
3. Pre-seed Tauri's plugin cache so it does not have to download it:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -87,13 +216,11 @@ distributions that don't package NSIS (e.g. Void Linux):
|
|||||||
# from https://github.com/tauri-apps/nsis-tauri-utils/releases
|
# from https://github.com/tauri-apps/nsis-tauri-utils/releases
|
||||||
```
|
```
|
||||||
|
|
||||||
With those in place, `npm run app:build:windows` produces the NSIS `-setup.exe` and the
|
The installer has been installed and run once in a Windows 11 virtual machine; it has not been tried on physical
|
||||||
portable `voiced.exe`.
|
hardware.
|
||||||
|
|
||||||
## Installing on Void Linux
|
## Installing on Void Linux
|
||||||
|
|
||||||
Two ways to get a native `.xbps` package.
|
|
||||||
|
|
||||||
### Prebuilt binary package (no xbps-src needed)
|
### Prebuilt binary package (no xbps-src needed)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -102,52 +229,97 @@ echo "repository=$(pwd)/build/void/repo" | sudo tee /etc/xbps.d/99-voiced-local.
|
|||||||
sudo xbps-install -Sy voiced
|
sudo xbps-install -Sy voiced
|
||||||
```
|
```
|
||||||
|
|
||||||
Launch **Voiced** from the application menu, or run `voiced`. Remove it with
|
The script runs `npx tauri build --no-bundle --features custom-protocol` (using your existing `node_modules`), stages
|
||||||
`sudo xbps-remove voiced`.
|
the install tree and runs `xbps-create` and `xbps-rindex`. Launch **Voiced** from the application menu or run
|
||||||
|
`voiced`; remove it with `sudo xbps-remove voiced`.
|
||||||
|
|
||||||
The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on
|
The package installs `/usr/bin/voiced`, a desktop entry, hicolor icons, the bundled fonts under
|
||||||
`libwebkit2gtk41`.
|
`/usr/share/fonts/voiced/<family>/`, the OFL texts under `/usr/share/licenses/voiced/`, and `INSTALL` and `REMOVE`
|
||||||
|
action scripts that run `fc-cache -f /usr/share/fonts/voiced` after install and removal. The `voiced/` font
|
||||||
**Fonts on Void.** The package also installs the bundled TTF fonts under
|
subdirectory avoids file conflicts with Void's `font-ibm-plex-ttf` and `noto-fonts-ttf`. It depends on
|
||||||
`/usr/share/fonts/voiced/<family>/` and each family's OFL licence under
|
`libwebkit2gtk41>=2.50`, `fontconfig`, `xdg-utils`, `hicolor-icon-theme` and `desktop-file-utils`.
|
||||||
`/usr/share/licenses/voiced/`. The `voiced/` subdirectory avoids file conflicts with Void's own
|
|
||||||
`font-ibm-plex-ttf` and `noto-fonts-ttf`. The `INSTALL` and `REMOVE` scripts in `packaging/void/`
|
|
||||||
run `fc-cache -f /usr/share/fonts/voiced` after install and removal. `xbps-create` packs them from
|
|
||||||
the destdir root (checked with xbps 0.60.7).
|
|
||||||
|
|
||||||
### Building through xbps-src
|
### Building through xbps-src
|
||||||
|
|
||||||
Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p void-packages/srcpkgs/voiced
|
mkdir -p void-packages/srcpkgs/voiced
|
||||||
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
|
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
|
||||||
cd void-packages && ./xbps-src pkg voiced
|
cd void-packages && ./xbps-src pkg voiced
|
||||||
```
|
```
|
||||||
|
|
||||||
See the comments at the top of the template about the private source archive and checksums.
|
`INSTALL` and `REMOVE` must sit next to the template; xbps-src packs them. The source is a tagged archive of the
|
||||||
|
repository (see the comments at the top of the template; `checksum=SKIP` until a tarball is published).
|
||||||
## Where data lives
|
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
|
||||||
| What | Location |
|
archive. The template runs `npm ci` (not `--omit=optional`: that removes
|
||||||
| ------------------- | ------------------------------------------------------------- |
|
the native rollup and esbuild binaries the Vite build needs, and `@napi-rs/canvas` is type-checked by `tsc`).
|
||||||
| Database | `<app-data>/voiced.db` — on Windows `%APPDATA%\com.bongbetic.voiced\` |
|
No OpenSSL is needed (rustls), and the
|
||||||
| Logos & signatures | `<app-data>/assets/` |
|
dependencies of the ERPNext and backup code (`reqwest`, `zip`) are pure Rust.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/ React + Carbon frontend
|
src/ React + Carbon frontend
|
||||||
views/ Onboarding · NewInvoice · InvoiceHistory · SeriesSettings · AppSettings
|
views/ Onboarding, NewInvoice, InvoiceHistory, InvoiceDetail, Clients, AppSettings, SeriesSettings
|
||||||
components/ AppShell · InvoicePreview · ImagePicker
|
components/ AppShell, TemplatePicker, PageSetupControls, PdfPreview, LogoBranding,
|
||||||
pdf/ InvoiceDocument (react-pdf template)
|
FontOverrideTable, DataBackupPanel, ErpnextSettingsPanel, RecordPaymentModal, ...
|
||||||
lib/ api · types · invoice · numberToWords · validators · format · pdf
|
hooks/ Export, PDF preview, user fonts, shortcuts
|
||||||
src-tauri/ Rust backend (Tauri commands, SQLite schema, asset store)
|
lib/ Typed Tauri wrapper (api.ts), money, GST helpers, exportFlow, historyExport,
|
||||||
public/brand/ Bongbetic + vendor brand assets
|
invoiceExportV1, ERPNext UI helpers
|
||||||
public/fonts/ IBM Plex (woff/woff2)
|
pdf/ PDF engine
|
||||||
packaging/void/ Void Linux packaging (xbps-src template + build script + desktop entry)
|
model/ buildRenderModel (invoice -> render model)
|
||||||
|
engine/ geometry (A4/Letter, margins), logo slots, layout fingerprint
|
||||||
|
templates/ <family>/ with tokens.ts, plan.ts, Layout.tsx; registry.ts, catalog.ts, contract.ts
|
||||||
|
blocks/ decor/ Shared layout blocks and decor
|
||||||
|
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
|
||||||
|
logo.rs Logo trimming, quantising, measuring
|
||||||
|
commands/ Tauri commands: invoice, series, clients, payments, presets, settings, assets,
|
||||||
|
archive, fonts, backup, files, raw (binary IPC), erpnext, logo
|
||||||
|
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 (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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ownership
|
## Tests and gates
|
||||||
|
|
||||||
© Bongbetic. All rights reserved. This is a private application.
|
Before a release (details in `docs/RELEASE.md`): `npm run build`, `npm test`, `npm run fonts:verify`,
|
||||||
|
`cargo test --manifest-path src-tauri/Cargo.toml`, and the real-webview self-test on a debug or installed build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri -- build --debug --no-bundle --features custom-protocol
|
||||||
|
VOICED_SELFTEST_OUT=/tmp/voiced-e2e/report.json XDG_DATA_HOME=/tmp/voiced-e2e/data \
|
||||||
|
XDG_CONFIG_HOME=/tmp/voiced-e2e/config timeout 180 src-tauri/target/debug/voiced
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is inert unless `VOICED_SELFTEST_OUT` is set; `ok: true` is expected in the report. The same checks run from
|
||||||
|
Settings, Diagnostics.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Licence and credit
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Release checklist
|
||||||
|
|
||||||
|
Run from the repository root on a quiet machine, on a branch cut from `main`.
|
||||||
|
|
||||||
|
## 1. Version
|
||||||
|
|
||||||
|
Keep these in step: `package.json` and the root entry of `package-lock.json`, `src-tauri/Cargo.toml` and
|
||||||
|
`src-tauri/Cargo.lock` (`cargo update --offline -p voiced`), `src-tauri/tauri.conf.json`, and `version=` plus
|
||||||
|
`revision=` in `packaging/void/template`. The PDF Creator string follows `package.json` (`src/lib/pdf.tsx`); layout
|
||||||
|
fingerprints do not include it, so goldens stay unchanged.
|
||||||
|
|
||||||
|
## 2. Gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm test # about 7 minutes
|
||||||
|
npm run fonts:verify
|
||||||
|
cargo test --manifest-path src-tauri/Cargo.toml
|
||||||
|
cargo tree -i aws-lc-sys --manifest-path src-tauri/Cargo.toml # must not match
|
||||||
|
cargo tree -i aws-lc-sys --manifest-path src-tauri/Cargo.toml --target x86_64-pc-windows-msvc # must not match
|
||||||
|
```
|
||||||
|
|
||||||
|
Real-webview self-test on the debug build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri -- build --debug --no-bundle --features custom-protocol
|
||||||
|
VOICED_SELFTEST_OUT=/tmp/voiced-e2e/report.json XDG_DATA_HOME=/tmp/voiced-e2e/data \
|
||||||
|
XDG_CONFIG_HOME=/tmp/voiced-e2e/config timeout 180 src-tauri/target/debug/voiced
|
||||||
|
```
|
||||||
|
|
||||||
|
`/tmp/voiced-e2e/report.json` must contain `"ok": true`. Repeat the same check on each installed build with
|
||||||
|
Settings, Diagnostics.
|
||||||
|
|
||||||
|
## 3. Void package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/void/build-xbps.sh
|
||||||
|
tar --zstd -tf build/void/repo/voiced-<version>_1.x86_64.xbps | less
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect `./usr/bin/voiced`, the desktop file, hicolor icons, `usr/share/fonts/voiced/<family>/*.ttf`,
|
||||||
|
`usr/share/licenses/voiced/*-OFL.txt`, `./INSTALL` and `./REMOVE`. Then install it into a clean container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman run --rm -v "$PWD/build/void/repo:/repo:Z" ghcr.io/void-linux/void-glibc-full sh -c '
|
||||||
|
xbps-install -Syu xbps && xbps-install -y -R /repo voiced &&
|
||||||
|
fc-list | grep -iE "montserrat|poppins|lustria|dm sans|inter|jost|open sans|ibm plex" &&
|
||||||
|
ls /usr/share/licenses/voiced && ldd /usr/bin/voiced | grep "not found"; xbps-remove -y voiced'
|
||||||
|
```
|
||||||
|
|
||||||
|
`ldd` must print no `not found` line, and `xbps-remove` must finish without errors. Optionally build with
|
||||||
|
`xbps-src` in a `void-packages` checkout (see the README).
|
||||||
|
|
||||||
|
## 4. Windows installer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run app:build:windows # standard installer
|
||||||
|
npm run app:build:windows:offline # only for machines without internet
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs `cargo-xwin`, `makensis` and the NSIS plugin cache (README, "Building for Windows"). Check the size of
|
||||||
|
`src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe` against the budget of about 25 MB. Optional:
|
||||||
|
sign it with `osslsigncode`. Never commit build outputs (`build/`, `dist/`, `src-tauri/target/`).
|
||||||
|
|
||||||
|
## 5. Manual first-run checklist (on a real machine, installed build, not `app:dev`)
|
||||||
|
|
||||||
|
Linux and Windows. Windows has never been run on real hardware, so treat it as untested until this passes.
|
||||||
|
|
||||||
|
- **Onboarding and Branding.** Complete first-run setup; upload a wide and a square logo, check the dark-theme
|
||||||
|
wordmark and the signature.
|
||||||
|
- **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.
|
||||||
|
- **Settings tabs.** Business, Branding, Bank and payments, Invoice defaults, Numbering, Templates and export, Fonts,
|
||||||
|
Data, Integrations: open each and save a change.
|
||||||
|
- **History, detail and payments.** Filter the list; open an invoice; record a payment with TDS; cancel one; export
|
||||||
|
CSV, JSON and `voiced.invoice.v1`.
|
||||||
|
- **Data.** Backup now, change something, restore the backup, restart, confirm the data returned and that
|
||||||
|
`backups/pre-restore-*` exists. Confirm the archive and fonts directories (Windows: under `%LOCALAPPDATA%`).
|
||||||
|
- **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. Open items
|
||||||
|
|
||||||
|
A chartered accountant's sign-off on the SAC code and the signature rule is still open.
|
||||||
@@ -156,3 +156,5 @@ Built as `src/pdf/templates/monolith/`: a standalone template (`tokens.ts` the t
|
|||||||
- **Elastic gaps.** The 88 pt band under the client (down to 24), the row padding (down to 6), the gaps above BILLED TO and under the client block (down to 14) and the two gaps in the footer (down to 8) give back Letter's missing 50 pt, and the overflow of an invoice that is a little too long for one page. A 2-item tax invoice with a bank block fits one A4 page that way; with 3 rows and a signature image the closing block moves to page 2 (the items and totals stay together on page 1).
|
- **Elastic gaps.** The 88 pt band under the client (down to 24), the row padding (down to 6), the gaps above BILLED TO and under the client block (down to 14) and the two gaps in the footer (down to 8) give back Letter's missing 50 pt, and the overflow of an invoice that is a little too long for one page. A 2-item tax invoice with a bank block fits one A4 page that way; with 3 rows and a signature image the closing block moves to page 2 (the items and totals stay together on page 1).
|
||||||
- **Amount in words and reverse charge** are the totals' left column (Medium 10.5, x 35..329). **Notes** and the **signature** (an image up to 150 x 45, or the signatory name and designation and the computer-generated line) form the band above the footer's top rule; notes taller than 110 pt flow as their own block before the closing.
|
- **Amount in words and reverse charge** are the totals' left column (Medium 10.5, x 35..329). **Notes** and the **signature** (an image up to 150 x 45, or the signatory name and designation and the computer-generated line) form the band above the footer's top rule; notes taller than 110 pt flow as their own block before the closing.
|
||||||
- **Footer position.** The closing block ends 24 pt above the "Generated with Voiced" line, so its bottom rule is at y 804 where the reference design's is at 812.
|
- **Footer position.** The closing block ends 24 pt above the "Generated with Voiced" line, so its bottom rule is at y 804 where the reference design's is at 812.
|
||||||
|
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (band, info gaps, row padding) alone, as in Marble: the footer's own two gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 4 or 5 rows, Letter with 4).
|
||||||
|
- **Flow notes guard.** The flow-notes block is preceded by a zero-height `TableGuard` asking for 84 pt (14 gap + 70 hold), so it never starts with less than its label and first line of room and collapses into zero-height lines at a page foot; it starts on the next page instead (Marble's guard).
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ Everything can be built with View backgroundColor and borders. Svg is only neede
|
|||||||
|
|
||||||
## Mapping to Voiced fields
|
## 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).
|
- Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned).
|
||||||
- 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'.
|
- '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}'.
|
- Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'.
|
||||||
|
|||||||
@@ -155,4 +155,5 @@ Built as `src/pdf/templates/cobalt/`: a standalone template (`tokens.ts` the typ
|
|||||||
- **Totals.** "Sub Total :", Discount, Taxable Value and the CGST / SGST / IGST rows (labelled "CGST 9% :") end at the label column's right edge (x 417.4 of the design); the amount in words and the reverse-charge line sit under the GRAND TOTAL bar, right-aligned in 9.5 pt grey. The PAYMENT METHOD bar is level with the GRAND TOTAL bar as in the design whenever the invoice still fits its page that way; with a longer stack (a GST invoice has up to five rows against the design's two) it moves up beside the first row, which gives back up to the stack's height (104 pt for five rows), and the bank lines follow it.
|
- **Totals.** "Sub Total :", Discount, Taxable Value and the CGST / SGST / IGST rows (labelled "CGST 9% :") end at the label column's right edge (x 417.4 of the design); the amount in words and the reverse-charge line sit under the GRAND TOTAL bar, right-aligned in 9.5 pt grey. The PAYMENT METHOD bar is level with the GRAND TOTAL bar as in the design whenever the invoice still fits its page that way; with a longer stack (a GST invoice has up to five rows against the design's two) it moves up beside the first row, which gives back up to the stack's height (104 pt for five rows), and the bank lines follow it.
|
||||||
- **Closing.** The divider, "Thank you for business with us!" and the terms (the notes; taller than 110 pt they flow as a block of their own before the closing) at the left, the signature at the right, then the icon footer: the footer rule with accents at both ends, and phone, e-mail and address with Carbon icons (26 pt boxes, sky). The footer is part of the closing block, so it is on the last page only, not on every page, and everything in it is audited. The contacts keep their natural width at 32.5 pt apart; when they do not fit, the address wraps into what is left and the phone and e-mail shrink only if the address would get under 120 pt.
|
- **Closing.** The divider, "Thank you for business with us!" and the terms (the notes; taller than 110 pt they flow as a block of their own before the closing) at the left, the signature at the right, then the icon footer: the footer rule with accents at both ends, and phone, e-mail and address with Carbon icons (26 pt boxes, sky). The footer is part of the closing block, so it is on the last page only, not on every page, and everything in it is audited. The contacts keep their natural width at 32.5 pt apart; when they do not fit, the address wraps into what is left and the phone and e-mail shrink only if the address would get under 120 pt.
|
||||||
- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): header to client (42 to 26 pt), client to table (30.5 to 16), last row to the totals (9.8 to 4), thank-you to terms (26 to 14) and terms to the footer rule (16 to 8). Type is never scaled.
|
- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): header to client (42 to 26 pt), client to table (30.5 to 16), last row to the totals (9.8 to 4), thank-you to terms (26 to 14) and terms to the footer rule (16 to 8). Type is never scaled.
|
||||||
|
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (header to client, client to table, last row to totals) alone, as in Marble: the terms and footer gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 7 rows).
|
||||||
- **Margins.** 54.2 pt at the top (the INVOICE line box starts there so its baseline lands at 87.2), 59.55 at the sides and 35.5 plus the 24 pt footer reserve at the foot, which puts the icons' bottom 59.5 pt above the page foot as in the design.
|
- **Margins.** 54.2 pt at the top (the INVOICE line box starts there so its baseline lands at 87.2), 59.55 at the sides and 35.5 plus the 24 pt footer reserve at the foot, which puts the icons' bottom 59.5 pt above the page foot as in the design.
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# voiced.invoice.v1
|
||||||
|
|
||||||
|
A neutral export of issued invoices for software Voiced has no direct integration with. Choose invoices in the
|
||||||
|
History list (checkboxes) or open one, then use "Export selected (voiced.invoice.v1 JSON)" or "(line items CSV)".
|
||||||
|
The code is `src/lib/invoiceExportV1.ts`; `src/lib/__golden__/invoice-v1.golden.json` is a full example.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Money is exact.** Every amount appears twice: `<name>_paise` (an integer) and `<name>` (a decimal string with two
|
||||||
|
places, e.g. `"7310.10"`). Nothing is a float. Rates (percent) are decimal strings such as `"9"` or `"6.25"`.
|
||||||
|
- **Deterministic.** The same invoices give the same bytes: fixed key order, no timestamps, input order kept.
|
||||||
|
- **JSON file** is an array with one object per invoice; each object carries `"schema": "voiced.invoice.v1"`.
|
||||||
|
- Cancelled invoices are included with `"cancelled": true` (their figures are kept; filter them out of sums).
|
||||||
|
- An unregistered supplier's plain "Invoice" has `document_type: "invoice"`, all tax heads zero and no supplier GSTIN.
|
||||||
|
|
||||||
|
## Invoice object
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `schema`, `number`, `issue_date`, `due_date` | `YYYY-MM-DD` dates; `due_date` is null when unset |
|
||||||
|
| `document_type` | `tax_invoice` or `invoice` |
|
||||||
|
| `status`, `cancelled`, `cancelled_at`, `cancel_reason` | `status` is `issued` or `cancelled` |
|
||||||
|
| `currency`, `po_number` | always `INR` |
|
||||||
|
| `supplier` | `name`, `address`, `gstin` (null if none), `pan`, `gst_registration`, `state {code, name}`; frozen at issue |
|
||||||
|
| `client` | `name`, `gstin`, `gst_category`, `address {text, line1, line2, city, pincode, state}`, `place_of_supply {code, name}` |
|
||||||
|
| `items[]` | `line`, `description`, `hsn_sac`, `quantity` (decimal string), `unit`, `rate`, `taxable_value` (each with `_paise`) |
|
||||||
|
| `tax` | `cgst`, `sgst`, `utgst`, `igst`, each `{rate, amount_paise, amount}`; heads that do not apply are zero |
|
||||||
|
| `totals` | `subtotal`, `discount`, `taxable_value`, `tax`, `total` (each with `_paise`), `amount_in_words` |
|
||||||
|
| `reverse_charge`, `notes` | |
|
||||||
|
| `payments[]` | `date`, `amount` (cash), `tds`, `mode`, `reference` (amounts with `_paise`) |
|
||||||
|
|
||||||
|
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
|
||||||
|
address parts and `gst_category` come from the client's saved record at export time (null or derived from the GSTIN
|
||||||
|
when the invoice has no saved client).
|
||||||
|
- **Supplier snapshot.** Only name, address, GSTIN, PAN, registration and state are exported; logo paths, e-mail and
|
||||||
|
phone are not.
|
||||||
|
|
||||||
|
## Line-items CSV
|
||||||
|
|
||||||
|
One row per line item, UTF-8 with a byte order mark and CRLF line ends; text cells that a spreadsheet would read as a
|
||||||
|
formula get a leading apostrophe. Columns: `invoice_number, issue_date, due_date, document_type, status, cancelled,
|
||||||
|
currency, supplier_name, supplier_gstin, client_name, client_gstin, client_gst_category, place_of_supply_code,
|
||||||
|
place_of_supply, reverse_charge, line, description, hsn_sac, quantity, unit, rate, taxable_value, invoice_discount,
|
||||||
|
invoice_cgst, invoice_sgst, invoice_utgst, invoice_igst, invoice_total`. The `invoice_*` amounts are filled on the first
|
||||||
|
row of each invoice only, so summing a column never counts an invoice twice.
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -6,6 +6,17 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<title>Voiced</title>
|
<title>Voiced</title>
|
||||||
|
<script>
|
||||||
|
// Apply the last-used theme before first paint so a dark theme never flashes white.
|
||||||
|
(function () {
|
||||||
|
var t = "g10";
|
||||||
|
try {
|
||||||
|
t = localStorage.getItem("voiced.theme") === "g100" ? "g100" : "g10";
|
||||||
|
} catch (e) {}
|
||||||
|
document.documentElement.classList.add("cds--" + t);
|
||||||
|
document.documentElement.setAttribute("data-carbon-theme", t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "voiced",
|
"name": "voiced",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.2.0",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Bongbetic",
|
"author": "Bongbetic",
|
||||||
@@ -22,7 +23,9 @@
|
|||||||
"decor:script-word": "node scripts/decor/script-word.mjs",
|
"decor:script-word": "node scripts/decor/script-word.mjs",
|
||||||
"decor:icons": "node scripts/decor/icons.mjs",
|
"decor:icons": "node scripts/decor/icons.mjs",
|
||||||
"thumbnails": "node scripts/thumbnails.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": {
|
"dependencies": {
|
||||||
"@carbon/icons-react": "^11.89.0",
|
"@carbon/icons-react": "^11.89.0",
|
||||||
@@ -39,15 +42,21 @@
|
|||||||
"react-is": "^19.3.0"
|
"react-is": "^19.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "10.0.1",
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
"@tauri-apps/cli": "^2.12.1",
|
"@tauri-apps/cli": "^2.12.1",
|
||||||
"@types/node": "^26.6.4",
|
"@types/node": "^26.6.4",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"clipper-lib": "^6.4.2",
|
"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",
|
"pdfjs-dist": "6.4.299",
|
||||||
"sass": "^1.105.1",
|
"sass": "^1.105.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "8.71.0",
|
||||||
"vite": "^7.3.6",
|
"vite": "^7.3.6",
|
||||||
"vitest": "^5.0.3"
|
"vitest": "^5.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ mkdir -p "$REPO"
|
|||||||
-n "$PKGVER" \
|
-n "$PKGVER" \
|
||||||
-s "Offline invoicing app by Bongbetic" \
|
-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." \
|
-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" \
|
-H "https://git.bongbetic.com/xavierk/Voiced" \
|
||||||
-m "Xavier Karma <xavier@bongbetic.com>" \
|
-m "Xavier Karma <xavier@bongbetic.com>" \
|
||||||
-D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
|
-D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
|
||||||
@@ -101,7 +101,9 @@ mkdir -p "$REPO"
|
|||||||
mv -f "$OUT"/${PKGVER}.*.xbps "$REPO/"
|
mv -f "$OUT"/${PKGVER}.*.xbps "$REPO/"
|
||||||
|
|
||||||
echo "==> Indexing local repository"
|
echo "==> Indexing local repository"
|
||||||
xbps-rindex -a "$REPO"/*.xbps
|
# -f re-adds a package whose version is already indexed. Without it a rebuild of the
|
||||||
|
# same version keeps the old checksum in the index and xbps-install rejects the new file.
|
||||||
|
xbps-rindex -f -a "$REPO"/*.xbps
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Package: $REPO/${PKGVER}.${ARCH}.xbps"
|
echo "Package: $REPO/${PKGVER}.${ARCH}.xbps"
|
||||||
|
|||||||
@@ -21,11 +21,14 @@
|
|||||||
# not install them. Copy them next to the template (see the commands above).
|
# not install them. Copy them next to the template (see the commands above).
|
||||||
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
|
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
|
||||||
# unless mkfontdir and mkfontscale are installed, hence these scripts.
|
# unless mkfontdir and mkfontscale are installed, hence these scripts.
|
||||||
# * If the build machine is offline, pre-populate the npm cache and Cargo
|
# * xbps-src builds without network access: vendor the crates (`cargo vendor`
|
||||||
# registry in the masterdir before building.
|
# in src-tauri/ plus a .cargo/config.toml pointing at vendor/) and pre-populate
|
||||||
|
# the npm cache in the source archive or masterdir before building.
|
||||||
|
# * No openssl-devel: the ERPNext client uses rustls (ring); reqwest and zip are
|
||||||
|
# pure Rust and need no extra system libraries.
|
||||||
#
|
#
|
||||||
pkgname=voiced
|
pkgname=voiced
|
||||||
version=0.1.0
|
version=1.2.0
|
||||||
revision=1
|
revision=1
|
||||||
archs="x86_64*"
|
archs="x86_64*"
|
||||||
hostmakedepends="rust nodejs pkg-config"
|
hostmakedepends="rust nodejs pkg-config"
|
||||||
@@ -33,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"
|
depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
|
||||||
short_desc="Offline invoicing app by Bongbetic"
|
short_desc="Offline invoicing app by Bongbetic"
|
||||||
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
||||||
license="custom:proprietary"
|
license="Apache-2.0"
|
||||||
homepage="https://git.bongbetic.com/xavierk/Voiced"
|
homepage="https://git.bongbetic.com/xavierk/Voiced"
|
||||||
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
|
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
|
||||||
checksum=SKIP
|
checksum=SKIP
|
||||||
@@ -43,7 +46,10 @@ compression="zstd"
|
|||||||
font_dirs="/usr/share/fonts/voiced"
|
font_dirs="/usr/share/fonts/voiced"
|
||||||
|
|
||||||
do_build() {
|
do_build() {
|
||||||
npm install --include=dev --no-audit --no-fund
|
# Not --omit=optional: that also drops rollup's and esbuild's native binaries (they are
|
||||||
|
# optional dependencies), so vite fails; and tsc in `npm run build` type-checks the
|
||||||
|
# @napi-rs/canvas test helpers, so that package must be installed too.
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
npm run build
|
npm run build
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
# `custom-protocol` embeds the frontend for production; without it the app
|
# `custom-protocol` embeds the frontend for production; without it the app
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -52,7 +52,6 @@ function wordCommands(font) {
|
|||||||
function rotate(cmds) {
|
function rotate(cmds) {
|
||||||
const pts = cmds.flatMap((c) => c.args);
|
const pts = cmds.flatMap((c) => c.args);
|
||||||
const xs = pts.filter((_, i) => i % 2 === 0);
|
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 inkLen = Math.max(...xs) - Math.min(...xs);
|
||||||
const s = INK_LENGTH / inkLen;
|
const s = INK_LENGTH / inkLen;
|
||||||
const map = (x, y) => [-y * s, -x * s];
|
const map = (x, y) => [-y * s, -x * s];
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# frappe/erpnext v15 plus the india_compliance app (installed per site, not globally)
|
||||||
|
FROM docker.io/frappe/erpnext:v15.121.6
|
||||||
|
USER frappe
|
||||||
|
RUN bench get-app --skip-assets --branch version-15 https://github.com/resilient-tech/india-compliance.git \
|
||||||
|
&& ls -1 apps > sites/apps.txt || true
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# ERPNext live end-to-end (podman)
|
||||||
|
|
||||||
|
Runs Voiced's ERPNext integration against real ERPNext v15 sites: one plain site and one with India Compliance.
|
||||||
|
Everything is rootless podman (`podman` and `podman-compose`, never docker). All names are prefixed `voiced-erp`.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `compose.yml` | Lean stack: MariaDB, one Redis, backend (gunicorn on `127.0.0.1:8088`), one worker. Project name `voiced-erp`. |
|
||||||
|
| `Containerfile.ic` | `frappe/erpnext:v15.121.6` plus the `india_compliance` app files (`version-15` branch). |
|
||||||
|
| `setup.sh` | Builds the image, starts the stack, creates both sites, prepares them, writes `.env.a` and `.env.b`. |
|
||||||
|
| `setup_site.py` | Runs inside the container: company, accounts, address, items, integration user and keys. |
|
||||||
|
| `../../src-tauri/src/integrations/erpnext/live_tests.rs` | The `#[ignore]` Rust tests. |
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts/erpnext-e2e
|
||||||
|
./setup.sh # first run pulls ~2 GB of images and takes about 10 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup.sh` creates two sites that one backend serves by Host header:
|
||||||
|
|
||||||
|
| Site | URL | Contents | Env file |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `127.0.0.1` | `http://127.0.0.1:8088` | ERPNext only | `.env.a` |
|
||||||
|
| `localhost` | `http://localhost:8088` | ERPNext + India Compliance | `.env.b` |
|
||||||
|
|
||||||
|
Both get the company "Voiced Test Co" (abbreviation `VTC`, INR, India, fiscal year 2026-27), the output tax accounts
|
||||||
|
`Output Tax CGST|SGST|UTGST|IGST - VTC` (India Compliance creates its own with the same names), `TDS Receivable - VTC`,
|
||||||
|
the bank account `HDFC - VTC`, a company address, the UOMs Hour/Minute/Second, the items `VOICED-SERVICE` (stock UOM Nos)
|
||||||
|
and `VOICED-HOURLY` (stock UOM Hour), and an integration user with a fresh API key and secret.
|
||||||
|
`.env.*` files hold those keys; they are git-ignored (`.env.*` in the root `.gitignore`) and must never be committed.
|
||||||
|
|
||||||
|
Run the Rust tests (they only touch the site named by the sourced env file):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a; . scripts/erpnext-e2e/.env.a; set +a # or .env.b for the India Compliance site
|
||||||
|
cargo test --manifest-path src-tauri/Cargo.toml live_ -- --ignored --test-threads=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables the tests read:
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `ERPNEXT_URL`, `ERPNEXT_KEY`, `ERPNEXT_SECRET` | Site address and the integration user's API key and secret. |
|
||||||
|
| `ERPNEXT_COMPANY` (`Voiced Test Co`), `ERPNEXT_ABBR` (`VTC`) | Company; account names derive from the abbreviation. |
|
||||||
|
| `ERPNEXT_IC` | `1` on the India Compliance site; the `live_ic_*` tests skip themselves otherwise. |
|
||||||
|
| `ERPNEXT_ROUNDING` | `banker` (ERPNext default) or `commercial`; decides what the half-paise test expects. |
|
||||||
|
| `ERPNEXT_BANK_ACCOUNT` (`HDFC - VTC`) | Account that receives payments. |
|
||||||
|
|
||||||
|
Each test builds its own in-memory Voiced database and a fresh invoice series (`L<time><n>/001`), so reruns never collide
|
||||||
|
with documents from earlier runs. Switch the rounding method of a site with
|
||||||
|
`podman exec -i voiced-erp-backend bash -c "cd /home/frappe/frappe-bench/sites && /home/frappe/frappe-bench/env/bin/python - 127.0.0.1 rounding=commercial" < setup_site.py`
|
||||||
|
(re-run `setup.sh` or copy the printed `ERPNEXT_*` lines if you need the new key; the key is regenerated on every run).
|
||||||
|
|
||||||
|
Tear down (touches only the `voiced-erp` project; the cached images stay):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman-compose -p voiced-erp -f compose.yml down -v
|
||||||
|
podman volume rm voiced-erp_voiced-erp-db voiced-erp_voiced-erp-sites voiced-erp_voiced-erp-logs # only if `down -v` left them
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes on rootless podman: the compose file has no healthchecks (podman's healthcheck timers need systemd), and the
|
||||||
|
backend and worker are started by `setup.sh` after the configurator has exited.
|
||||||
|
|
||||||
|
## Integration user (feeds the main README)
|
||||||
|
|
||||||
|
Create a dedicated user in ERPNext (User list, type System User) with **the roles "Accounts User" and "Sales User"**.
|
||||||
|
Then open the user, "Settings" tab, "API Access", "Generate Keys", and copy the secret once.
|
||||||
|
|
||||||
|
Why both (verified live on plain ERPNext and with India Compliance; `setup_site.py` creates exactly this user):
|
||||||
|
|
||||||
|
- "Accounts User": create and submit Sales Invoices (draft, insert-and-submit, submit-later) and Payment Entries; read
|
||||||
|
Accounts, Items, UOMs, Cost Centers, Price Lists, Item Groups, Company, the Sales Invoice naming series and (with
|
||||||
|
India Compliance) GST Settings; attach a private PDF to the invoice.
|
||||||
|
- "Sales User": look up and create Customers and Addresses, read Customer Groups, Territories and the tax templates.
|
||||||
|
With "Accounts User" alone a fresh site answers HTTP 403 on Customer.
|
||||||
|
|
||||||
|
No tax template is used (Voiced always sends explicit tax rows), so the tax-template list is only a convenience.
|
||||||
|
Do not use Administrator or System Manager keys. Roles on a user are cached by Frappe: after changing them run
|
||||||
|
`bench clear-cache` or the next request may still use the old set.
|
||||||
|
|
||||||
|
## Results (what was verified live)
|
||||||
|
|
||||||
|
Versions: Frappe 15.121.3, ERPNext 15.121.6, India Compliance 15.32.0 (the `version-15` branch; commit `db73b3e` at the time of this run, so a later image build may differ), MariaDB 11.8,
|
||||||
|
Redis 8.6, API v2 naming available (Frappe 15.73 or newer).
|
||||||
|
|
||||||
|
Verified on both sites unless marked IC (India Compliance site only):
|
||||||
|
|
||||||
|
- Connection test: user, frappe/erpnext/india_compliance versions, India Compliance detection, no warnings when the
|
||||||
|
accounts match. Option lists load (companies, addresses, accounts, UOMs, cost centers, groups, series).
|
||||||
|
- Sales Invoice push in mirror mode (API v2, `name` = Voiced number): CGST+SGST, IGST, discount (`apply_discount_on:
|
||||||
|
Net Total`), fractional hours, repeated descriptions on code-less rows, item-code rows from a preset, remarks. The
|
||||||
|
grand total, the tax amounts per head and the posting date equal Voiced's; `disable_rounded_total` works.
|
||||||
|
- PDF attachment: uploaded through `upload_file`, `is_private` is 1.
|
||||||
|
- Idempotency: a second push is a no-op; with the local sync row deleted, the 409 path adopts the existing document
|
||||||
|
(no duplicate, no second PDF).
|
||||||
|
- Submit: insert then submit in a second push (v2 `method/submit`), and series mode through the v1 `run_method` route.
|
||||||
|
- Series mode: ERPNext names the document, the Voiced number is in `remarks`, and a lost sync row is recovered by the
|
||||||
|
remarks lookup.
|
||||||
|
- Payments: `get_payment_entry` plus overrides. `bank_account` is the Account name and lands in `paid_to`;
|
||||||
|
`paid_amount = received_amount = cash`; the reference's `allocated_amount = cash + TDS`; TDS is one `deductions` row
|
||||||
|
`{account, cost_center, amount: +TDS}`; the invoice's outstanding falls by cash + TDS; partial payments leave the rest.
|
||||||
|
- Customer and Address shape (IC: `gstin`, `gst_category` on both; state exact name; link to the Customer).
|
||||||
|
- IC: `place_of_supply` as `29-Karnataka`; Voiced's 38 state names equal India Compliance's `gst_state` list; HSN/SAC
|
||||||
|
must be 6 or 8 digits (GST Settings `min_hsn_digits` was 6) and is enforced at submit; the 16-character name rule;
|
||||||
|
GST-account warnings (mismatching CGST account, company without GST accounts, a too-long next number).
|
||||||
|
|
||||||
|
Defects found and fixed (each has a mock-based test in the normal suite):
|
||||||
|
|
||||||
|
1. `load_options`: the company-address list asked for the India Compliance-only `gstin` field and failed with HTTP 417
|
||||||
|
on a plain site. It now retries without it.
|
||||||
|
2. Code-less rows with a fractional quantity of hours/minutes failed ("Quantity cannot be a fraction ... UOM Nos"):
|
||||||
|
ERPNext defaults the row's stock UOM to Nos without an item. Such rows now also send `stock_uom` = the row UOM.
|
||||||
|
3. Rounding: ERPNext's default Rounding Method is Banker's Rounding; Voiced rounds half-paise up. On ties (for example
|
||||||
|
9% of 10.50) the totals differ by 1 paise. A created document whose total differs is now a `conflict`: kept as a
|
||||||
|
draft, never submitted, no PDF, with a message naming the fix (System Settings, Rounding Method, "Commercial
|
||||||
|
Rounding"). The existing-document conflict message carries the same hint. With Commercial Rounding every case matched.
|
||||||
|
4. A lost link to a client's address created a new address on every first push. The customer's matching address (same
|
||||||
|
first line and PIN) is reused. (`creation` in the order-by is ambiguous with a Dynamic Link filter and gives HTTP 500.)
|
||||||
|
5. A re-adopted document got a second copy of the PDF. A file of the same name is now looked up first.
|
||||||
|
6. IC refuses `is_reverse_charge = 1` unless the tax rows are negative amounts on separate "RCM" accounts, which would
|
||||||
|
make ERPNext's total differ from Voiced's. It is sent as 0 and the push warns.
|
||||||
|
7. IC's 16-character rule is now checked before the POST in mirror mode (a readable, local refusal that points to series
|
||||||
|
naming); ERPNext's own message is "Transaction Name must be 16 characters or fewer to meet GST requirements".
|
||||||
|
8. A TDS-only payment (no cash) got ERPNext's bare "Paid Amount is mandatory"; it is refused locally with the reason.
|
||||||
|
9. A fractional quantity on a whole-number UOM is refused by ERPNext with a readable message; Voiced appends what to
|
||||||
|
change (unit mapping). No local pre-check: whether "Nos" is whole-number is a site setting.
|
||||||
|
|
||||||
|
Limits and notes:
|
||||||
|
|
||||||
|
- Rounding setting cannot be read by a restricted user, so it is detected by comparing the totals after creating the
|
||||||
|
draft, not in the connection test.
|
||||||
|
- With India Compliance, an invoice with a missing or short HSN/SAC is created as a draft and fails at submit; the
|
||||||
|
readable ERPNext message ("HSN/SAC must exist and should be 6 or 8 digits long for the following row numbers") is
|
||||||
|
shown.
|
||||||
|
- A mirrored item row with an item code whose stock UOM differs from the row UOM works for whole quantities
|
||||||
|
(`conversion_factor` 1); a fractional quantity needs the item's stock UOM to allow fractions.
|
||||||
|
- One intermittent failure was seen once in the half-paise test on the India Compliance site (the push of a non-tie
|
||||||
|
case failed; three later full runs and a single-test run passed). The cause was not captured.
|
||||||
|
|
||||||
|
Not verified:
|
||||||
|
|
||||||
|
- ERPNext v14 and v16 (the v16 images that exist on some machines were not used), other India Compliance versions.
|
||||||
|
- Windows and WebView2; the Tauri commands and UI (the tests call the Rust push/payment functions directly).
|
||||||
|
- Overseas/SEZ customers, UTGST supplier states, multi-currency, GST Settings with "Round Off GST Values" on,
|
||||||
|
reverse charge booked on RCM accounts, e-invoice/e-waybill (India Compliance API features), a TLS or reverse-proxied
|
||||||
|
site (only plain `http://` on a loopback address), Frappe Cloud or any site with `allow_cors` or rate limits.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Lean ERPNext v15 stack for Voiced's live integration tests (project name: voiced-erp).
|
||||||
|
# db + redis + backend (gunicorn, published on 127.0.0.1:8088) + one worker. No nginx/websocket/scheduler.
|
||||||
|
# setup.sh starts it in order (db, redis, configurator, then backend and worker); do not `up` it blindly.
|
||||||
|
# Image: voiced-erp-ic:v15 = frappe/erpnext v15 + the india_compliance app files (see Containerfile.ic).
|
||||||
|
name: voiced-erp
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: docker.io/library/mariadb:11.8
|
||||||
|
container_name: voiced-erp-db
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
- --skip-character-set-client-handshake
|
||||||
|
- --innodb-buffer-pool-size=256M
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: admin
|
||||||
|
volumes:
|
||||||
|
- voiced-erp-db:/var/lib/mysql
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: docker.io/library/redis:8.6-alpine
|
||||||
|
container_name: voiced-erp-redis
|
||||||
|
|
||||||
|
configurator:
|
||||||
|
image: localhost/voiced-erp-ic:v15
|
||||||
|
container_name: voiced-erp-configurator
|
||||||
|
restart: "no"
|
||||||
|
entrypoint: ["bash", "-c"]
|
||||||
|
command:
|
||||||
|
- >
|
||||||
|
wait-for-it -t 120 db:3306;
|
||||||
|
ls -1 apps > sites/apps.txt;
|
||||||
|
bench set-config -g db_host db;
|
||||||
|
bench set-config -gp db_port 3306;
|
||||||
|
bench set-config -g redis_cache redis://redis:6379;
|
||||||
|
bench set-config -g redis_queue redis://redis:6379;
|
||||||
|
bench set-config -g redis_socketio redis://redis:6379;
|
||||||
|
bench set-config -gp socketio_port 9000;
|
||||||
|
volumes:
|
||||||
|
- voiced-erp-sites:/home/frappe/frappe-bench/sites
|
||||||
|
- voiced-erp-logs:/home/frappe/frappe-bench/logs
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: localhost/voiced-erp-ic:v15
|
||||||
|
container_name: voiced-erp-backend
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8088:8000"
|
||||||
|
volumes:
|
||||||
|
- voiced-erp-sites:/home/frappe/frappe-bench/sites
|
||||||
|
- voiced-erp-logs:/home/frappe/frappe-bench/logs
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: localhost/voiced-erp-ic:v15
|
||||||
|
container_name: voiced-erp-worker
|
||||||
|
command: ["bench", "worker", "--queue", "short,default,long"]
|
||||||
|
volumes:
|
||||||
|
- voiced-erp-sites:/home/frappe/frappe-bench/sites
|
||||||
|
- voiced-erp-logs:/home/frappe/frappe-bench/logs
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voiced-erp-db:
|
||||||
|
voiced-erp-sites:
|
||||||
|
voiced-erp-logs:
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Brings up the voiced-erp podman project and prepares two ERPNext v15 sites for the live tests:
|
||||||
|
# 127.0.0.1 plain ERPNext -> .env.a (URL http://127.0.0.1:8088)
|
||||||
|
# localhost ERPNext + India Compliance -> .env.b (URL http://localhost:8088)
|
||||||
|
# Both are served by the same backend; Frappe picks the site from the Host header.
|
||||||
|
# Safe to re-run: existing sites are kept and the integration user's key is regenerated.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
COMPOSE=(podman-compose -p voiced-erp -f compose.yml)
|
||||||
|
IMAGE=localhost/voiced-erp-ic:v15
|
||||||
|
BENCH_SITES=/home/frappe/frappe-bench/sites
|
||||||
|
|
||||||
|
podman image exists "$IMAGE" || podman build -t "$IMAGE" -f Containerfile.ic .
|
||||||
|
|
||||||
|
"${COMPOSE[@]}" up -d db redis configurator
|
||||||
|
until [ "$(podman inspect -f '{{.State.Status}}' voiced-erp-configurator)" = exited ]; do sleep 2; done
|
||||||
|
[ "$(podman inspect -f '{{.State.ExitCode}}' voiced-erp-configurator)" = 0 ] || { echo "configurator failed: podman logs voiced-erp-configurator" >&2; exit 1; }
|
||||||
|
"${COMPOSE[@]}" up -d backend worker
|
||||||
|
|
||||||
|
in_backend() { podman exec -i voiced-erp-backend bash -c "$1"; }
|
||||||
|
|
||||||
|
for site in 127.0.0.1 localhost; do
|
||||||
|
default=""; [ "$site" = 127.0.0.1 ] && default="--set-default"
|
||||||
|
in_backend "test -d $BENCH_SITES/$site || bench new-site $site --mariadb-user-host-login-scope='%' \
|
||||||
|
--admin-password=admin --db-root-username=root --db-root-password=admin --install-app erpnext $default" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
until curl -fsS http://127.0.0.1:8088/api/method/ping >/dev/null 2>&1; do sleep 2; done
|
||||||
|
|
||||||
|
# setup_site.py prints `ENV KEY=VALUE` lines; turn them into a sourceable env file (untracked: .env.* is ignored).
|
||||||
|
write_env() { # site, url, file, extra args...
|
||||||
|
local site=$1 url=$2 file=$3; shift 3
|
||||||
|
in_backend "cd $BENCH_SITES && /home/frappe/frappe-bench/env/bin/python - $site $*" < setup_site.py \
|
||||||
|
| sed -n 's/^ENV \([A-Z_]*\)=\(.*\)$/export \1="\2"/p' > "$file"
|
||||||
|
echo "export ERPNEXT_URL=\"$url\"" >> "$file"
|
||||||
|
chmod 600 "$file"
|
||||||
|
}
|
||||||
|
write_env 127.0.0.1 http://127.0.0.1:8088 .env.a
|
||||||
|
write_env localhost http://localhost:8088 .env.b ic
|
||||||
|
|
||||||
|
echo "Ready. Source .env.a (plain) or .env.b (India Compliance), then run the live tests (see README.md)."
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Prepare one ERPNext site for Voiced's live integration tests.
|
||||||
|
|
||||||
|
Run inside the backend container (see README.md):
|
||||||
|
podman exec -i voiced-erp-backend python - <site> [ic] [rounding=commercial] < setup_site.py
|
||||||
|
|
||||||
|
Creates (idempotently): the Indian company "Voiced Test Co" (abbr VTC, INR), the fiscal year, GST output tax
|
||||||
|
accounts, a bank account, a company address, an integration user with API keys, and a few Items/UOMs.
|
||||||
|
Prints one line `ENV KEY=VALUE` per value the Rust live tests need. Never run against a real site.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
SITE = sys.argv[1]
|
||||||
|
IC = "ic" in sys.argv[2:]
|
||||||
|
# `rounding=commercial` switches System Settings > Rounding Method (the ERPNext default is Banker's Rounding).
|
||||||
|
ROUNDING = {"commercial": "Commercial Rounding", "banker": "Banker's Rounding"}.get(
|
||||||
|
next((a.split("=", 1)[1] for a in sys.argv[2:] if a.startswith("rounding=")), ""), None
|
||||||
|
)
|
||||||
|
ABBR = "VTC"
|
||||||
|
COMPANY = "Voiced Test Co"
|
||||||
|
|
||||||
|
frappe.init(site=SITE, sites_path="/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.set_user("Administrator")
|
||||||
|
|
||||||
|
|
||||||
|
def out(key, value):
|
||||||
|
print(f"ENV {key}={value}")
|
||||||
|
|
||||||
|
|
||||||
|
def exists(doctype, name):
|
||||||
|
return bool(frappe.db.exists(doctype, name))
|
||||||
|
|
||||||
|
|
||||||
|
def insert(doc):
|
||||||
|
d = frappe.get_doc(doc)
|
||||||
|
d.insert(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 1. site, optionally India Compliance (before the wizard so it can add its own accounts) ----
|
||||||
|
if IC and "india_compliance" not in frappe.get_installed_apps():
|
||||||
|
from frappe.installer import install_app
|
||||||
|
|
||||||
|
install_app("india_compliance", verbose=False)
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# ---- 2. setup wizard ----
|
||||||
|
if not frappe.db.get_single_value("System Settings", "setup_complete"):
|
||||||
|
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
|
||||||
|
|
||||||
|
setup_complete(
|
||||||
|
{
|
||||||
|
"language": "English",
|
||||||
|
"country": "India",
|
||||||
|
"timezone": "Asia/Kolkata",
|
||||||
|
"currency": "INR",
|
||||||
|
"full_name": "Voiced Admin",
|
||||||
|
"email": "admin@voiced.test",
|
||||||
|
"password": "admin",
|
||||||
|
"company_name": COMPANY,
|
||||||
|
"company_abbr": ABBR,
|
||||||
|
"domains": ["Services"],
|
||||||
|
"chart_of_accounts": "Standard",
|
||||||
|
"fy_start_date": "2026-04-01",
|
||||||
|
"fy_end_date": "2027-03-31",
|
||||||
|
"bank_account": "HDFC",
|
||||||
|
"setup_demo": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# setup_complete may run its stages on a worker; wait for it.
|
||||||
|
for _ in range(120):
|
||||||
|
frappe.db.rollback()
|
||||||
|
if frappe.db.get_single_value("System Settings", "setup_complete") and exists("Company", COMPANY):
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
frappe.clear_cache()
|
||||||
|
frappe.db.commit()
|
||||||
|
assert exists("Company", COMPANY), "setup wizard did not create the company"
|
||||||
|
|
||||||
|
# ---- 3. accounts ----
|
||||||
|
duties = frappe.db.get_value("Account", {"company": COMPANY, "account_name": "Duties and Taxes", "is_group": 1}, "name")
|
||||||
|
assert duties, "Duties and Taxes group not found"
|
||||||
|
for head in ("CGST", "SGST", "UTGST", "IGST"):
|
||||||
|
name = f"Output Tax {head} - {ABBR}"
|
||||||
|
if not exists("Account", name):
|
||||||
|
insert(
|
||||||
|
{
|
||||||
|
"doctype": "Account",
|
||||||
|
"account_name": f"Output Tax {head}",
|
||||||
|
"parent_account": duties,
|
||||||
|
"company": COMPANY,
|
||||||
|
"account_type": "Tax",
|
||||||
|
"root_type": "Liability",
|
||||||
|
"is_group": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tds = f"TDS Receivable - {ABBR}"
|
||||||
|
if not exists("Account", tds):
|
||||||
|
parent = frappe.db.get_value("Account", {"company": COMPANY, "account_name": "Current Assets", "is_group": 1}, "name")
|
||||||
|
insert(
|
||||||
|
{
|
||||||
|
"doctype": "Account",
|
||||||
|
"account_name": "TDS Receivable",
|
||||||
|
"parent_account": parent,
|
||||||
|
"company": COMPANY,
|
||||||
|
"account_type": "Tax",
|
||||||
|
"root_type": "Asset",
|
||||||
|
"is_group": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 4. company address (India Compliance needs one with a GSTIN-ish state) ----
|
||||||
|
addr_name = f"{COMPANY}-Billing"
|
||||||
|
if not exists("Address", addr_name):
|
||||||
|
d = {
|
||||||
|
"doctype": "Address",
|
||||||
|
"address_title": COMPANY,
|
||||||
|
"address_type": "Billing",
|
||||||
|
"address_line1": "1 Test Street",
|
||||||
|
"city": "Mumbai",
|
||||||
|
"state": "Maharashtra",
|
||||||
|
"pincode": "400001",
|
||||||
|
"country": "India",
|
||||||
|
"links": [{"link_doctype": "Company", "link_name": COMPANY}],
|
||||||
|
}
|
||||||
|
if IC:
|
||||||
|
d["gst_category"] = "Registered Regular"
|
||||||
|
d["gstin"] = "27AAPFU0939F1ZV"
|
||||||
|
insert(d)
|
||||||
|
|
||||||
|
# ---- 5. items / UOMs ----
|
||||||
|
for uom in ("Hour", "Minute", "Second"):
|
||||||
|
if not exists("UOM", uom):
|
||||||
|
insert({"doctype": "UOM", "uom_name": uom})
|
||||||
|
for code, uom in (("VOICED-SERVICE", "Nos"), ("VOICED-HOURLY", "Hour")):
|
||||||
|
if not exists("Item", code):
|
||||||
|
d = {
|
||||||
|
"doctype": "Item",
|
||||||
|
"item_code": code,
|
||||||
|
"item_name": code,
|
||||||
|
"item_group": "Services",
|
||||||
|
"stock_uom": uom,
|
||||||
|
"is_stock_item": 0,
|
||||||
|
}
|
||||||
|
if IC:
|
||||||
|
d["gst_hsn_code"] = "998314"
|
||||||
|
insert(d)
|
||||||
|
|
||||||
|
# ---- 5b. rounding method ----
|
||||||
|
if ROUNDING:
|
||||||
|
frappe.db.set_single_value("System Settings", "rounding_method", ROUNDING)
|
||||||
|
frappe.db.commit()
|
||||||
|
frappe.clear_cache()
|
||||||
|
|
||||||
|
# ---- 6. integration user ----
|
||||||
|
USER = f"voiced-integration@{SITE.replace('.', '-')}.test"
|
||||||
|
# The role set that was verified live (with and without India Compliance): "Accounts User" creates and submits Sales
|
||||||
|
# Invoices and Payment Entries and reads the accounting masters; "Sales User" adds the Customer and Address rights
|
||||||
|
# (look up, create) and the tax-template list. Accounts User alone gets HTTP 403 on Customer.
|
||||||
|
ROLES = ["Accounts User", "Sales User"]
|
||||||
|
if not exists("User", USER):
|
||||||
|
insert(
|
||||||
|
{
|
||||||
|
"doctype": "User",
|
||||||
|
"email": USER,
|
||||||
|
"first_name": "Voiced",
|
||||||
|
"last_name": "Integration",
|
||||||
|
"send_welcome_email": 0,
|
||||||
|
"user_type": "System User",
|
||||||
|
"roles": [{"role": r} for r in ROLES],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user = frappe.get_doc("User", USER)
|
||||||
|
user.set("roles", [{"role": r} for r in ROLES])
|
||||||
|
secret = frappe.generate_hash(length=32)
|
||||||
|
if not user.api_key:
|
||||||
|
user.api_key = frappe.generate_hash(length=15)
|
||||||
|
user.api_secret = secret
|
||||||
|
user.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
frappe.clear_cache(user=USER)
|
||||||
|
frappe.clear_cache()
|
||||||
|
|
||||||
|
out("ERPNEXT_KEY", user.api_key)
|
||||||
|
out("ERPNEXT_SECRET", secret)
|
||||||
|
out("ERPNEXT_COMPANY", COMPANY)
|
||||||
|
out("ERPNEXT_ABBR", ABBR)
|
||||||
|
out("ERPNEXT_IC", "1" if IC else "0")
|
||||||
|
out("ERPNEXT_USER", USER)
|
||||||
|
out("ERPNEXT_ROUNDING", "commercial" if frappe.db.get_single_value("System Settings", "rounding_method") == "Commercial Rounding" else "banker")
|
||||||
|
print(json.dumps({"roles": ROLES}))
|
||||||
|
frappe.destroy()
|
||||||
@@ -116,7 +116,7 @@ async function main() {
|
|||||||
const { computeLogoPlacement, slotFor } = await loadPlacement();
|
const { computeLogoPlacement, slotFor } = await loadPlacement();
|
||||||
mkdirSync(OUT_DIR, { recursive: true });
|
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);
|
const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height);
|
||||||
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
|
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
|
||||||
const syn = syntheticLogos(createCanvas);
|
const syn = syntheticLogos(createCanvas);
|
||||||
|
|||||||
@@ -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"));
|
||||||
@@ -107,7 +107,7 @@ async function main() {
|
|||||||
invoice: { clientName: TOKEN, clientAddress: LONG, notes: `Note: ${TOKEN} ${LONG}` },
|
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"));
|
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));
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -509,6 +509,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -532,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -545,10 +555,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core_detect"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -875,6 +891,20 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"core_detect",
|
||||||
|
"multiversion_no_op",
|
||||||
|
"rustversion",
|
||||||
|
"scopeguard",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1244,6 +1274,17 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -1415,6 +1456,25 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1531,6 +1591,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1541,6 +1602,21 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.21"
|
version = "0.1.21"
|
||||||
@@ -1560,9 +1636,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1891,6 +1969,36 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-macros",
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
"log",
|
||||||
|
"simd_cesu8",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"walkdir",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-macros"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"simd_cesu8",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2090,6 +2198,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2152,6 +2270,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multiversion_no_op"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2439,6 +2563,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2866,21 +2996,31 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -2916,6 +3056,20 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsqlite-vfs"
|
name = "rsqlite-vfs"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2979,12 +3133,91 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"jni 0.22.4",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-platform-verifier-android",
|
||||||
|
"rustls-webpki",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"webpki-root-certs",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier-android"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -2994,6 +3227,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3051,6 +3293,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -3175,6 +3440,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.24.0"
|
version = "3.24.0"
|
||||||
@@ -3272,6 +3549,22 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_cesu8"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||||
|
dependencies = [
|
||||||
|
"rustc_version",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3396,6 +3689,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
@@ -3459,6 +3758,27 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3480,7 +3800,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -3490,7 +3810,7 @@ dependencies = [
|
|||||||
"gdkwayland-sys",
|
"gdkwayland-sys",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
"gtk",
|
"gtk",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
@@ -3546,7 +3866,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -3763,7 +4083,7 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
@@ -3786,7 +4106,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
@@ -3972,9 +4292,31 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.19"
|
version = "0.7.19"
|
||||||
@@ -3984,6 +4326,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
|
"libc",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@@ -4188,6 +4531,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4211,6 +4560,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.26"
|
version = "1.0.26"
|
||||||
@@ -4223,6 +4578,12 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4286,15 +4647,17 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voiced"
|
name = "voiced"
|
||||||
version = "0.1.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"flate2",
|
"flate2",
|
||||||
"image",
|
"image",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rusqlite_migration",
|
"rusqlite_migration",
|
||||||
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -4306,7 +4669,9 @@ dependencies = [
|
|||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4508,6 +4873,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-root-certs"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.39.1"
|
version = "0.39.1"
|
||||||
@@ -4672,6 +5046,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -4699,6 +5084,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -4988,7 +5382,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -5149,6 +5543,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -5182,6 +5582,19 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "8.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"flate2",
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"memchr",
|
||||||
|
"typed-path",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voiced"
|
name = "voiced"
|
||||||
version = "0.1.0"
|
version = "1.2.0"
|
||||||
|
license = "Apache-2.0"
|
||||||
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
||||||
authors = ["Bongbetic"]
|
authors = ["Bongbetic"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -28,8 +29,14 @@ thiserror = "2"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
|
# ERPNext integration: all HTTP runs in Rust. TLS is rustls with the `ring` provider only (no aws-lc,
|
||||||
|
# which does not cross-compile cleanly under cargo-xwin); roots come from the OS via rustls-platform-verifier.
|
||||||
|
reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "http2", "charset", "system-proxy", "json", "multipart", "query"] }
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||||
|
tokio = { version = "1", features = ["time"] }
|
||||||
|
|
||||||
# Desktop-only plugins (not available on mobile targets).
|
# Desktop-only plugins (not available on mobile targets).
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
@@ -38,6 +45,7 @@ tauri-plugin-window-state = "2"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
tokio = { version = "1", features = ["rt", "macros", "net", "io-util", "time"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Production builds embed the frontend and serve it over the custom asset
|
# Production builds embed the frontend and serve it over the custom asset
|
||||||
|
|||||||
@@ -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.
|
/// 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> {
|
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 {
|
if !valid {
|
||||||
return Err("The stored archive hash is malformed".to_string());
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
if status != "issued" && status != "cancelled" {
|
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);
|
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"))
|
std::fs::create_dir_all(path.parent().expect("archive path has a parent"))
|
||||||
.map_err(|e| format!("Could not create the archive folder: {e}"))?;
|
.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.
|
// 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 {
|
if !present {
|
||||||
write_atomic(&path, bytes)?;
|
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> {
|
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 path = archive_path(local_dir, &sha)?;
|
||||||
let bytes = std::fs::read(&path).map_err(|e| {
|
let bytes = std::fs::read(&path).map_err(|e| {
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
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> {
|
fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> {
|
||||||
let row: Option<Option<String>> = conn
|
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()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
row.ok_or_else(|| "Invoice not found".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()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".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.
|
// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread.
|
||||||
#[tauri::command]
|
#[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 bytes = raw_body(&request)?;
|
||||||
let headers = header_map(&request);
|
let headers = header_map(&request);
|
||||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
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]
|
#[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())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
archive_status_impl(&conn, invoice_id)
|
archive_status_impl(&conn, invoice_id)
|
||||||
}
|
}
|
||||||
@@ -195,7 +218,10 @@ mod tests {
|
|||||||
let mut conn = conn_with_invoice("issued");
|
let mut conn = conn_with_invoice("issued");
|
||||||
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
assert_eq!(sha.len(), 64);
|
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();
|
let status = archive_status_impl(&conn, 1).unwrap();
|
||||||
assert!(status.archived);
|
assert!(status.archived);
|
||||||
@@ -205,7 +231,10 @@ mod tests {
|
|||||||
|
|
||||||
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
assert_eq!(again, sha);
|
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);
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,19 +250,27 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = conn_with_invoice("issued");
|
let mut conn = conn_with_invoice("issued");
|
||||||
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
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!(err.contains("already has an archived original"), "{err}");
|
||||||
assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha);
|
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]
|
#[test]
|
||||||
fn non_pdf_and_oversize_bodies_are_refused() {
|
fn non_pdf_and_oversize_bodies_are_refused() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = conn_with_invoice("issued");
|
let mut conn = conn_with_invoice("issued");
|
||||||
assert!(archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
assert!(
|
||||||
|
archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("not a PDF"));
|
.contains("not a PDF")
|
||||||
|
);
|
||||||
let mut big = b"%PDF-".to_vec();
|
let mut big = b"%PDF-".to_vec();
|
||||||
big.resize(MAX_ARCHIVE_BYTES + 1, 0);
|
big.resize(MAX_ARCHIVE_BYTES + 1, 0);
|
||||||
assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1"))
|
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() {
|
fn read_archive_detects_corruption_and_missing_files() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = conn_with_invoice("issued");
|
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 sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
let file = dir.path().join("archive").join(format!("{sha}.pdf"));
|
let file = dir.path().join("archive").join(format!("{sha}.pdf"));
|
||||||
|
|
||||||
std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap();
|
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();
|
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.
|
// Archiving the same bytes again restores the file.
|
||||||
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
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() {
|
fn a_malformed_stored_hash_never_becomes_a_path() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let conn = conn_with_invoice("issued");
|
let conn = conn_with_invoice("issued");
|
||||||
conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", [])
|
conn.execute(
|
||||||
|
"UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed"));
|
assert!(read_archive_impl(&conn, dir.path(), 1)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("malformed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
if raw.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||||
return Err("Asset path must not contain '..'".into());
|
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
|
let root = data_dir
|
||||||
.join("assets")
|
.join("assets")
|
||||||
.canonicalize()
|
.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> {
|
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
|
||||||
let (ext, _) = sniff_image(bytes)?;
|
let (ext, _) = sniff_image(bytes)?;
|
||||||
let dir = assets_dir(data_dir)?;
|
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())?;
|
std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?;
|
||||||
Ok(format!("assets/{file}"))
|
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> {
|
fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> {
|
||||||
let raw = Path::new(path);
|
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.
|
// Removing something that is already gone is fine, but an escape attempt is not.
|
||||||
if std::fs::symlink_metadata(&full).is_err() {
|
if std::fs::symlink_metadata(&full).is_err() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -163,7 +176,10 @@ mod tests {
|
|||||||
fn sniffing_accepts_only_png_and_jpeg() {
|
fn sniffing_accepts_only_png_and_jpeg() {
|
||||||
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
|
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
|
||||||
assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg"));
|
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"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"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err());
|
||||||
assert!(sniff_image(b"").is_err());
|
assert!(sniff_image(b"").is_err());
|
||||||
@@ -173,7 +189,10 @@ mod tests {
|
|||||||
fn write_ignores_the_name_and_stores_a_relative_path() {
|
fn write_ignores_the_name_and_stores_a_relative_path() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let stored = write_asset(dir.path(), "Logo", JPEG).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!(dir.path().join(&stored).is_file());
|
||||||
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
|
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
|
||||||
let uri = asset_data_uri(dir.path(), &stored).unwrap();
|
let uri = asset_data_uri(dir.path(), &stored).unwrap();
|
||||||
@@ -210,7 +229,10 @@ mod tests {
|
|||||||
let absolute = dir.path().join(&stored);
|
let absolute = dir.path().join(&stored);
|
||||||
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
|
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
|
||||||
assert_eq!(by_relative, by_absolute);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
|
use crate::gst;
|
||||||
use crate::models::Client;
|
use crate::models::Client;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use rusqlite::{params, Row};
|
use rusqlite::{params, Connection, Row};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
|
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,
|
||||||
|
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)";
|
||||||
|
|
||||||
fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
||||||
Ok(Client {
|
Ok(Client {
|
||||||
id: Some(row.get(0)?),
|
id: Some(row.get(0)?),
|
||||||
@@ -12,17 +27,57 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
|||||||
state_code: row.get(4)?,
|
state_code: row.get(4)?,
|
||||||
po_number: row.get(5)?,
|
po_number: row.get(5)?,
|
||||||
created_at: row.get(6)?,
|
created_at: row.get(6)?,
|
||||||
|
address_line1: row.get(7)?,
|
||||||
|
address_line2: row.get(8)?,
|
||||||
|
city: row.get(9)?,
|
||||||
|
pincode: row.get(10)?,
|
||||||
|
gst_category: row.get(11)?,
|
||||||
|
default_notes: row.get(12)?,
|
||||||
|
payment_terms_days: row.get(13)?,
|
||||||
|
invoice_count: row.get(14)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
|
||||||
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
|
||||||
let conn = state.db.lock().map_err(|e| e.to_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)
|
||||||
|
.map(|(_, n)| *n)
|
||||||
|
.unwrap_or("");
|
||||||
|
let place = [city.trim(), state_name]
|
||||||
|
.iter()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let pin = pincode.trim();
|
||||||
|
let last = match (place.is_empty(), pin.is_empty()) {
|
||||||
|
(false, false) => format!("{place} - {pin}"),
|
||||||
|
(false, true) => place,
|
||||||
|
(true, false) => pin.to_string(),
|
||||||
|
(true, true) => String::new(),
|
||||||
|
};
|
||||||
|
[line1.trim(), line2.trim(), last.as_str()]
|
||||||
|
.iter()
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_clients_impl(conn: &Connection) -> Result<Vec<Client>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(&format!(
|
||||||
"SELECT id, name, address, gstin, state_code, po_number, created_at
|
"SELECT {CLIENT_COLS} FROM clients c ORDER BY c.name COLLATE NOCASE ASC"
|
||||||
FROM clients ORDER BY name COLLATE NOCASE ASC",
|
))
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], map_client)
|
.query_map([], map_client)
|
||||||
@@ -32,29 +87,101 @@ pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn save_client_impl(conn: &Connection, client: Client) -> Result<Client, String> {
|
||||||
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
let name = client.name.trim().to_string();
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
if name.is_empty() {
|
||||||
|
return Err("Client name is required".into());
|
||||||
|
}
|
||||||
|
let gstin = match client.gstin.trim().to_ascii_uppercase().as_str() {
|
||||||
|
"" | "NA" => String::new(),
|
||||||
|
g => {
|
||||||
|
gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?;
|
||||||
|
g.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let state_code = client.state_code.trim().to_string();
|
||||||
|
if !state_code.is_empty() && !gst::state_exists(&state_code) {
|
||||||
|
return Err(format!("\"{state_code}\" is not a valid GST state code"));
|
||||||
|
}
|
||||||
|
if !gstin.is_empty() && !state_code.is_empty() && gstin[0..2] != state_code {
|
||||||
|
return Err(format!(
|
||||||
|
"GSTIN starts with state code {}, but the client's state is {}",
|
||||||
|
&gstin[0..2],
|
||||||
|
state_code
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !CATEGORIES.contains(&client.gst_category.as_str()) {
|
||||||
|
return Err(format!("Unknown GST category \"{}\"", client.gst_category));
|
||||||
|
}
|
||||||
|
let pincode = client.pincode.trim().to_string();
|
||||||
|
if !pincode.is_empty() && !(pincode.len() == 6 && pincode.bytes().all(|b| b.is_ascii_digit())) {
|
||||||
|
return Err("Pincode must be 6 digits".into());
|
||||||
|
}
|
||||||
|
if let Some(days) = client.payment_terms_days {
|
||||||
|
if !(0..=365).contains(&days) {
|
||||||
|
return Err("Payment terms must be between 0 and 365 days".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let line1 = client.address_line1.trim().to_string();
|
||||||
|
let line2 = client.address_line2.trim().to_string();
|
||||||
|
let city = client.city.trim().to_string();
|
||||||
|
// The structured parts win when present; otherwise the incoming (legacy or free-text) address is kept.
|
||||||
|
let address = if line1.is_empty() && line2.is_empty() && city.is_empty() && pincode.is_empty() {
|
||||||
|
client.address.clone()
|
||||||
|
} else {
|
||||||
|
compose_address(&line1, &line2, &city, &state_code, &pincode)
|
||||||
|
};
|
||||||
|
|
||||||
let id = match client.id {
|
let id = match client.id {
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
conn.execute(
|
let n = conn
|
||||||
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6",
|
.execute(
|
||||||
params![client.name, client.address, client.gstin, client.state_code, client.po_number, id],
|
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5,
|
||||||
|
address_line1=?6, address_line2=?7, city=?8, pincode=?9, gst_category=?10,
|
||||||
|
default_notes=?11, payment_terms_days=?12
|
||||||
|
WHERE id=?13",
|
||||||
|
params![
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
gstin,
|
||||||
|
state_code,
|
||||||
|
client.po_number,
|
||||||
|
line1,
|
||||||
|
line2,
|
||||||
|
city,
|
||||||
|
pincode,
|
||||||
|
client.gst_category,
|
||||||
|
client.default_notes,
|
||||||
|
client.payment_terms_days,
|
||||||
|
id
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err("Client not found".into());
|
||||||
|
}
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
address_line1, address_line2, city, pincode, gst_category, default_notes,
|
||||||
|
payment_terms_days)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
params![
|
params![
|
||||||
client.name,
|
name,
|
||||||
client.address,
|
address,
|
||||||
client.gstin,
|
gstin,
|
||||||
client.state_code,
|
state_code,
|
||||||
client.po_number,
|
client.po_number,
|
||||||
chrono::Utc::now().to_rfc3339()
|
chrono::Utc::now().to_rfc3339(),
|
||||||
|
line1,
|
||||||
|
line2,
|
||||||
|
city,
|
||||||
|
pincode,
|
||||||
|
client.gst_category,
|
||||||
|
client.default_notes,
|
||||||
|
client.payment_terms_days
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -62,17 +189,186 @@ pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, Str
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1",
|
&format!("SELECT {CLIENT_COLS} FROM clients c WHERE c.id = ?1"),
|
||||||
params![id],
|
params![id],
|
||||||
map_client,
|
map_client,
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Deleting is allowed even when invoices reference the client: invoices carry their own
|
||||||
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
/// name/address/GSTIN snapshot and `invoices.client_id` has no foreign key, so issued invoices are
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
/// untouched (their id simply no longer resolves). The UI warns with `invoice_count` first.
|
||||||
|
pub fn delete_client_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||||
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
|
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_clients_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
save_client_impl(&conn, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
delete_client_impl(&conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::open_in_memory;
|
||||||
|
|
||||||
|
fn blank(name: &str) -> Client {
|
||||||
|
serde_json::from_value(serde_json::json!({ "id": null, "name": name })).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_address_orders_parts() {
|
||||||
|
assert_eq!(
|
||||||
|
compose_address("12 MG Road", "Floor 2", "Bengaluru", "29", "560001"),
|
||||||
|
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
|
||||||
|
);
|
||||||
|
assert_eq!(compose_address("", "", "", "", ""), "");
|
||||||
|
assert_eq!(compose_address(" A ", "", "", "", "560001"), "A\n560001");
|
||||||
|
assert_eq!(compose_address("", "", "Pune", "", ""), "Pune");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_round_trip_composes_address() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let mut c = blank("Acme");
|
||||||
|
c.address_line1 = "12 MG Road".into();
|
||||||
|
c.address_line2 = "Floor 2".into();
|
||||||
|
c.city = "Bengaluru".into();
|
||||||
|
c.pincode = "560001".into();
|
||||||
|
c.state_code = "29".into();
|
||||||
|
c.gstin = "29abcde1234f1zw".into();
|
||||||
|
c.gst_category = "registered_regular".into();
|
||||||
|
c.default_notes = "Net 15".into();
|
||||||
|
c.payment_terms_days = Some(15);
|
||||||
|
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.gstin, "29ABCDE1234F1ZW");
|
||||||
|
let listed = list_clients_impl(&conn).unwrap();
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
let l = &listed[0];
|
||||||
|
assert_eq!(l.address_line1, "12 MG Road");
|
||||||
|
assert_eq!(l.gst_category, "registered_regular");
|
||||||
|
assert_eq!(l.default_notes, "Net 15");
|
||||||
|
assert_eq!(l.payment_terms_days, Some(15));
|
||||||
|
assert_eq!(l.invoice_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_address_is_kept_when_no_structured_parts() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let mut c = blank("Legacy");
|
||||||
|
c.address = "Old style\nfree text".into();
|
||||||
|
let saved = save_client_impl(&conn, c).unwrap();
|
||||||
|
assert_eq!(saved.address, "Old style\nfree text");
|
||||||
|
assert_eq!(saved.gst_category, "unregistered");
|
||||||
|
// Editing a field other than the address leaves it alone.
|
||||||
|
let mut edit = saved.clone();
|
||||||
|
edit.po_number = "PO-1".into();
|
||||||
|
let again = save_client_impl(&conn, edit).unwrap();
|
||||||
|
assert_eq!(again.address, "Old style\nfree text");
|
||||||
|
assert_eq!(again.id, saved.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_changes_the_row_and_unknown_id_fails() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let saved = save_client_impl(&conn, blank("A")).unwrap();
|
||||||
|
let mut edit = saved.clone();
|
||||||
|
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!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||||
|
let mut ghost = blank("Ghost");
|
||||||
|
ghost.id = Some(999);
|
||||||
|
assert!(save_client_impl(&conn, ghost).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_rejects_bad_input() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
assert!(save_client_impl(&conn, blank(" "))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("name"));
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
|
||||||
|
assert!(save_client_impl(&conn, c).unwrap_err().contains("GSTIN"));
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.gstin = "29ABCDE1234F1ZW".into();
|
||||||
|
c.state_code = "27".into();
|
||||||
|
assert!(save_client_impl(&conn, c).unwrap_err().contains("state"));
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.gstin = "NA".into();
|
||||||
|
assert_eq!(save_client_impl(&conn, c).unwrap().gstin, "");
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.state_code = "99".into();
|
||||||
|
assert!(save_client_impl(&conn, c).is_err());
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.gst_category = "weird".into();
|
||||||
|
assert!(save_client_impl(&conn, c).unwrap_err().contains("category"));
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.pincode = "56001".into();
|
||||||
|
assert!(save_client_impl(&conn, c).unwrap_err().contains("Pincode"));
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.payment_terms_days = Some(400);
|
||||||
|
assert!(save_client_impl(&conn, c).is_err());
|
||||||
|
// Only the valid "NA" client above was stored; every rejected save left no row behind.
|
||||||
|
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_keeps_referencing_invoices_and_count_is_listed() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let saved = save_client_impl(&conn, blank("Referenced")).unwrap();
|
||||||
|
let id = saved.id.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, client_id, client_name, client_address, created_at, updated_at)
|
||||||
|
VALUES ('T-1', '2026-01-01', ?1, 'Referenced', 'Snap addr', 'now', 'now')",
|
||||||
|
params![id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(list_clients_impl(&conn).unwrap()[0].invoice_count, 1);
|
||||||
|
|
||||||
|
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)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
//! Tauri commands for the ERPNext settings: read/save the configuration, test a connection and load the
|
||||||
|
//! lists the settings dropdowns need. The API secret never leaves this module: `erpnext_get_config` returns
|
||||||
|
//! `apiSecretSet` only, and every command resolves a blank secret to the stored one on the Rust side.
|
||||||
|
//! The push commands send issued invoices and payments (see `integrations::erpnext::push`); the database lock is
|
||||||
|
//! 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::push::{self, PaymentPushResult, PushResult};
|
||||||
|
use crate::integrations::SyncStatus;
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
pub fn get_config_impl(conn: &Connection) -> Result<ErpnextConfigView, String> {
|
||||||
|
Ok(config::load(conn)?.view())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
let stored = config::load(conn)?;
|
||||||
|
input.resolve(&stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT number FROM invoices ORDER BY id")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let invalid_numbers = stmt
|
||||||
|
.query_map([], |r| r.get::<_, String>(0))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| !ic_number_ok(n))
|
||||||
|
.take(5)
|
||||||
|
.collect();
|
||||||
|
let next_number = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT prefix, padding, next_number FROM invoice_series WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next));
|
||||||
|
Ok(LocalFacts {
|
||||||
|
vendor_registered: registration != "unregistered",
|
||||||
|
invalid_numbers,
|
||||||
|
next_number,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn erpnext_get_config(state: State<AppState>) -> Result<ErpnextConfigView, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
get_config_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
save_config_impl(&conn, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tests the form values as typed (saved or not). The database lock is released before any network call.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn erpnext_test_connection(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
config: ErpnextConfigInput,
|
||||||
|
) -> Result<ConnectionTest, String> {
|
||||||
|
let (cfg, local) = {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
(resolve_config(&conn, config)?, local_facts(&conn)?)
|
||||||
|
};
|
||||||
|
let client = ErpClient::from_config(&cfg)?;
|
||||||
|
Ok(discovery::test_connection(&client, &cfg, &local).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the dropdown lists. Company-scoped lists use `config.company`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn erpnext_load_options(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
config: ErpnextConfigInput,
|
||||||
|
) -> Result<ErpnextOptions, String> {
|
||||||
|
let cfg = {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
resolve_config(&conn, config)?
|
||||||
|
};
|
||||||
|
let client = ErpClient::from_config(&cfg)?;
|
||||||
|
Ok(client.load_options(&cfg.company).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client for the saved settings, or a readable reason why there is none.
|
||||||
|
fn saved_client(state: &AppState) -> Result<ErpClient, String> {
|
||||||
|
let cfg = {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(ErpClient::from_config(&cfg)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends one issued invoice. `submit` omitted follows the "submit on push" setting. A failure comes back as a
|
||||||
|
/// result with `ok: false` (and is recorded), not as an `Err`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn erpnext_push_invoice(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: i64,
|
||||||
|
submit: Option<bool>,
|
||||||
|
) -> Result<PushResult, String> {
|
||||||
|
let http = saved_client(&state)?;
|
||||||
|
Ok(push::push_invoice(&state.db, &state.local_data_dir, &http, id, submit).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends several invoices one after another; one row's failure never stops the others.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn erpnext_push_invoices(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
ids: Vec<i64>,
|
||||||
|
submit: Option<bool>,
|
||||||
|
) -> Result<Vec<PushResult>, String> {
|
||||||
|
let http = saved_client(&state)?;
|
||||||
|
Ok(push::push_invoices(&state.db, &state.local_data_dir, &http, &ids, submit).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn erpnext_sync_status(state: State<AppState>, id: i64) -> Result<SyncStatus, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
push::sync_status(&conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sync state of every invoice that has one, for the History list.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn erpnext_sync_statuses(state: State<AppState>) -> Result<Vec<SyncStatus>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
push::sync_statuses(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<base>/app/sales-invoice/<name>` for an invoice that was sent.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn erpnext_open_url(state: State<AppState>, id: i64) -> Result<String, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
push::open_url(&conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
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());
|
||||||
|
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();
|
||||||
|
assert!(view.api_secret_set);
|
||||||
|
let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap();
|
||||||
|
assert!(!shown.contains("super-secret-value"));
|
||||||
|
assert!(shown.contains("\"apiSecretSet\":true"));
|
||||||
|
assert!(shown.contains("\"company\":\"Test Co\""));
|
||||||
|
|
||||||
|
// Saving again with a blank secret keeps it for the connection test too.
|
||||||
|
save_config_impl(&conn, input(json!({ "company": "Other Co" }))).unwrap();
|
||||||
|
let resolved = resolve_config(&conn, input(json!({}))).unwrap();
|
||||||
|
assert_eq!(resolved.api_secret.expose(), "super-secret-value");
|
||||||
|
assert_eq!(resolved.company, "Test Co");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
assert_eq!(resolved.api_secret.expose(), "typed-not-saved");
|
||||||
|
assert!(!get_config_impl(&conn).unwrap().api_secret_set);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_facts_report_registration_numbers_and_the_next_number() {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let facts = local_facts(&conn).unwrap();
|
||||||
|
assert!(!facts.vendor_registered);
|
||||||
|
assert!(facts.invalid_numbers.is_empty());
|
||||||
|
assert!(facts.next_number.is_some());
|
||||||
|
|
||||||
|
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')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let facts = local_facts(&conn).unwrap();
|
||||||
|
assert!(facts.vendor_registered);
|
||||||
|
assert_eq!(facts.invalid_numbers, ["INVOICE/2026/000001"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
//! User-visible files: exports written to a path from the save dialog, and the
|
//! User-visible files: exports written to a path from the save dialog, and the
|
||||||
//! reveal/open helpers used afterwards.
|
//! 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 crate::AppState;
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -25,7 +27,9 @@ pub fn write_export_file_impl(
|
|||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
return Err("Nothing to write: the file is empty".to_string());
|
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)
|
std::fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||||
write_atomic(&path, bytes)?;
|
write_atomic(&path, bytes)?;
|
||||||
@@ -38,7 +42,11 @@ pub fn write_export_file_impl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_last_export_dir_impl(conn: &Connection) -> Result<String, String> {
|
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))
|
conn.query_row(
|
||||||
|
"SELECT last_export_dir FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +79,11 @@ fn reveal_fallback_command(path: &Path) -> Command {
|
|||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
#[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");
|
let mut cmd = Command::new("xdg-open");
|
||||||
cmd.arg(folder);
|
cmd.arg(folder);
|
||||||
cmd
|
cmd
|
||||||
@@ -90,7 +102,9 @@ fn open_fallback_command(path: &Path) -> Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
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
|
// 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).
|
// (explorer.exe, for one, exits non-zero even on success).
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -100,7 +114,10 @@ fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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 bytes = raw_body(&request)?;
|
||||||
let headers = header_map(&request);
|
let headers = header_map(&request);
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
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]
|
#[tauri::command]
|
||||||
pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||||
let path = require_existing(&path)?;
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
spawn_detached(open_fallback_command(&path))
|
spawn_detached(open_fallback_command(&path))
|
||||||
@@ -141,7 +162,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn encode(path: &Path) -> String {
|
fn encode(path: &Path) -> String {
|
||||||
percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC)
|
percent_encoding::utf8_percent_encode(
|
||||||
|
&path.to_string_lossy(),
|
||||||
|
percent_encoding::NON_ALPHANUMERIC,
|
||||||
|
)
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,8 +176,12 @@ mod tests {
|
|||||||
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
|
||||||
// Non-ASCII, spaces and a folder that does not exist yet.
|
// 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 target = dir
|
||||||
let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap();
|
.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!(PathBuf::from(&written), target);
|
||||||
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7");
|
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -170,7 +198,10 @@ mod tests {
|
|||||||
fn rejects_empty_relative_and_missing_paths() {
|
fn rejects_empty_relative_and_missing_paths() {
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] {
|
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!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err());
|
||||||
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
@@ -191,7 +222,9 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
assert!(require_existing("").is_err());
|
assert!(require_existing("").is_err());
|
||||||
let missing = dir.path().join("nope.pdf");
|
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());
|
assert!(require_existing(&dir.path().to_string_lossy()).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
//! (`fvar`, `CFF2`: only the default instance would embed), files without outlines, and fonts whose OS/2 `fsType`
|
//! (`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
|
//! forbids embedding. The sfnt is parsed by hand (no font crate is available offline); only the tables needed
|
||||||
//! to describe the font are read.
|
//! 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 crate::AppState;
|
||||||
use flate2::read::ZlibDecoder;
|
use flate2::read::ZlibDecoder;
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
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 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;
|
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()) {
|
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));
|
tables.push((tag, off, len));
|
||||||
}
|
}
|
||||||
@@ -135,7 +140,10 @@ impl<'a> Sfnt<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn utf16be(b: &[u8]) -> String {
|
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)
|
String::from_utf16_lossy(&units)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +209,11 @@ impl<'a> Cmap<'a> {
|
|||||||
best = Some((rank, offset, format));
|
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 {
|
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 comp = be32(data, rec + 8).ok_or_else(bad)? as usize;
|
||||||
let orig = be32(data, rec + 12).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 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;
|
total += (orig + 3) & !3;
|
||||||
if orig > MAX_FONT_BYTES || total > MAX_FONT_BYTES {
|
if orig > MAX_FONT_BYTES || total > MAX_FONT_BYTES {
|
||||||
return Err("The font expands to more than the 16 MB limit".into());
|
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 {
|
} else if comp < orig {
|
||||||
let mut out = Vec::with_capacity(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.
|
// 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 {
|
if out.len() != orig {
|
||||||
return Err(bad());
|
return Err(bad());
|
||||||
}
|
}
|
||||||
@@ -301,7 +318,11 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
|
|||||||
} else {
|
} else {
|
||||||
return Err(bad());
|
return Err(bad());
|
||||||
};
|
};
|
||||||
entries.push(Entry { tag, checksum, bytes });
|
entries.push(Entry {
|
||||||
|
tag,
|
||||||
|
checksum,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
entries.sort_by_key(|e| e.tag);
|
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();
|
let cff = font.table(b"CFF ").is_some();
|
||||||
if !cff && (font.table(b"glyf").is_none() || font.table(b"loca").is_none()) {
|
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 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 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 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 os2 = font.table(b"OS/2");
|
||||||
let fs_type = os2.and_then(|t| be16(t, 8)).unwrap_or(0);
|
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 fs_selection = os2.and_then(|t| be16(t, 62)).unwrap_or(0);
|
||||||
let mac_style = be16(head, 44).unwrap_or(0);
|
let mac_style = be16(head, 44).unwrap_or(0);
|
||||||
// fsSelection bit 0 = italic, bit 9 = oblique; head.macStyle bit 1 = italic.
|
// 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 cmap = Cmap::new(cmap_table).ok_or("The font has no usable Unicode character map")?;
|
||||||
let has_rupee = cmap.has(0x20B9);
|
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());
|
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 family = name_string(name, 16)
|
||||||
let subfamily = name_string(name, 17).or_else(|| name_string(name, 2)).unwrap_or_default();
|
.or_else(|| name_string(name, 1))
|
||||||
let full_name = name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string());
|
.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();
|
let postscript_name = name_string(name, 6).unwrap_or_default();
|
||||||
if family.is_empty() && full_name.is_empty() {
|
if family.is_empty() && full_name.is_empty() {
|
||||||
return Err("The font has no readable name".into());
|
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
|
// storage
|
||||||
|
|
||||||
fn font_path(local_dir: &Path, sha256: &str, format: &str) -> Result<PathBuf, String> {
|
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 {
|
if !valid {
|
||||||
return Err("The stored font hash is malformed".to_string());
|
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> {
|
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)
|
conn.query_row(
|
||||||
|
&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"),
|
||||||
|
params![id],
|
||||||
|
map_row,
|
||||||
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| e.to_string())
|
.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!(
|
let sql = format!(
|
||||||
"SELECT {COLS} FROM user_fonts {} ORDER BY face, weight, style, id",
|
"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 mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
||||||
let rows = stmt.query_map([], map_row).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.
|
/// 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 {
|
fn clean_file_name(raw: &str) -> String {
|
||||||
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
|
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
|
||||||
let cleaned: String = base.chars().filter(|c| !c.is_control()).take(120).collect();
|
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(
|
pub fn import_font_impl(
|
||||||
@@ -541,7 +596,10 @@ pub fn import_font_impl(
|
|||||||
req: &ImportRequest,
|
req: &ImportRequest,
|
||||||
) -> Result<UserFont, String> {
|
) -> Result<UserFont, String> {
|
||||||
if !FACES.contains(&req.face) {
|
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 {
|
if !req.licence_acknowledged {
|
||||||
return Err("Confirm that you hold a licence for this font before importing it".into());
|
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)?;
|
let path = font_path(local_dir, &info.sha256, &info.format)?;
|
||||||
std::fs::create_dir_all(path.parent().expect("font path has a parent"))
|
std::fs::create_dir_all(path.parent().expect("font path has a parent"))
|
||||||
.map_err(|e| format!("Could not create the fonts folder: {e}"))?;
|
.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 {
|
if !present {
|
||||||
write_atomic(&path, &sfnt)?;
|
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> {
|
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())?;
|
let font = get_font(conn, id)?.ok_or_else(|| "Font not found".to_string())?;
|
||||||
if referenced_by_invoice(conn, &font.sha256)? {
|
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);
|
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
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if shared > 0 {
|
if shared > 0 {
|
||||||
return Ok(None);
|
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)
|
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 tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||||
let file = retire(&tx, local_dir, id)?;
|
let file = retire(&tx, local_dir, id)?;
|
||||||
let still_there: bool = tx
|
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())?
|
.map_err(|e| e.to_string())?
|
||||||
!= 0;
|
!= 0;
|
||||||
tx.commit().map_err(|e| e.to_string())?;
|
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.
|
// Best effort, as in import: the row is already gone.
|
||||||
let _ = std::fs::remove_file(path);
|
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).
|
/// 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
|
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()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let format = format.ok_or_else(|| "This font is not in the font library".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)
|
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 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,
|
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,
|
None => None,
|
||||||
Some("normal") => Some(false),
|
Some("normal") => Some(false),
|
||||||
Some("italic") => Some(true),
|
Some("italic") => Some(true),
|
||||||
Some(_) => return Err("x-style must be normal or italic".into()),
|
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);
|
let licence_acknowledged = headers
|
||||||
Ok(ImportRequest { face, file_name, weight, italic, licence_acknowledged })
|
.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.
|
// 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]
|
#[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 bytes = raw_body(&request)?;
|
||||||
let headers = header_map(&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 face = decode_header_path(required_header(&headers, "x-face")?)?;
|
||||||
let mut headers = headers;
|
let mut headers = headers;
|
||||||
headers.insert("x-face".to_string(), face);
|
headers.insert("x-face".to_string(), face);
|
||||||
@@ -714,19 +833,28 @@ pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
list_user_fonts_impl(&conn, include_hidden.unwrap_or(false))
|
list_user_fonts_impl(&conn, include_hidden.unwrap_or(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
user_font_bytes_impl(&conn, &state.local_data_dir, &sha256).map(Response::new)
|
user_font_bytes_impl(&conn, &state.local_data_dir, &sha256).map(Response::new)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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())?;
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
remove_user_font_impl(&mut conn, &state.local_data_dir, id)
|
remove_user_font_impl(&mut conn, &state.local_data_dir, id)
|
||||||
}
|
}
|
||||||
@@ -740,7 +868,9 @@ mod tests {
|
|||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn jost(name: &str) -> Vec<u8> {
|
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()
|
std::fs::read(path).unwrap()
|
||||||
}
|
}
|
||||||
fn regular() -> Vec<u8> {
|
fn regular() -> Vec<u8> {
|
||||||
@@ -781,7 +911,11 @@ mod tests {
|
|||||||
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
|
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||||
enc.write_all(raw).unwrap();
|
enc.write_all(raw).unwrap();
|
||||||
let z = enc.finish().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];
|
let mut out = vec![0u8; 44 + 20 * n];
|
||||||
out[0..4].copy_from_slice(b"wOFF");
|
out[0..4].copy_from_slice(b"wOFF");
|
||||||
@@ -803,7 +937,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn request<'a>(face: &'a str, ack: bool) -> ImportRequest<'a> {
|
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]
|
#[test]
|
||||||
@@ -821,9 +961,17 @@ mod tests {
|
|||||||
assert_eq!(info.sha256, sha256_hex(®ular()));
|
assert_eq!(info.sha256, sha256_hex(®ular()));
|
||||||
assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings);
|
assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings);
|
||||||
assert!(info.warnings[0].contains("rupee"));
|
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();
|
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);
|
assert_eq!(inspect_bytes(&bold()).unwrap().1.weight, 700);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,7 +983,9 @@ mod tests {
|
|||||||
let mut ttc = regular();
|
let mut ttc = regular();
|
||||||
ttc[0..4].copy_from_slice(b"ttcf");
|
ttc[0..4].copy_from_slice(b"ttcf");
|
||||||
assert!(inspect_bytes(&ttc).unwrap_err().contains("collections"));
|
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());
|
assert!(inspect_bytes(b"short").is_err());
|
||||||
let mut big = regular();
|
let mut big = regular();
|
||||||
big.resize(MAX_FONT_BYTES + 1, 0);
|
big.resize(MAX_FONT_BYTES + 1, 0);
|
||||||
@@ -856,7 +1006,9 @@ mod tests {
|
|||||||
fn fs_type_rules() {
|
fn fs_type_rules() {
|
||||||
let mut restricted = regular();
|
let mut restricted = regular();
|
||||||
set_fs_type(&mut restricted, FS_RESTRICTED);
|
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).
|
// A more permissive bit alongside wins (least restrictive).
|
||||||
let mut both = regular();
|
let mut both = regular();
|
||||||
@@ -865,11 +1017,21 @@ mod tests {
|
|||||||
|
|
||||||
let mut preview = regular();
|
let mut preview = regular();
|
||||||
set_fs_type(&mut preview, FS_PREVIEW_PRINT);
|
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();
|
let mut no_subset = regular();
|
||||||
set_fs_type(&mut no_subset, FS_NO_SUBSETTING);
|
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();
|
let mut bitmap = regular();
|
||||||
set_fs_type(&mut bitmap, FS_BITMAP_ONLY);
|
set_fs_type(&mut bitmap, FS_BITMAP_ONLY);
|
||||||
@@ -880,13 +1042,18 @@ mod tests {
|
|||||||
fn refuses_a_font_without_outlines() {
|
fn refuses_a_font_without_outlines() {
|
||||||
let mut font = regular();
|
let mut font = regular();
|
||||||
rename_table(&mut font, b"glyf", b"XXXX");
|
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]
|
#[test]
|
||||||
fn unpacks_woff_to_the_same_font() {
|
fn unpacks_woff_to_the_same_font() {
|
||||||
let woff = to_woff(®ular());
|
let woff = to_woff(®ular());
|
||||||
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();
|
let (sfnt, info) = inspect_bytes(&woff).unwrap();
|
||||||
assert_eq!(info.source_format, "woff");
|
assert_eq!(info.source_format, "woff");
|
||||||
assert_eq!(info.family, "Jost");
|
assert_eq!(info.family, "Jost");
|
||||||
@@ -897,7 +1064,12 @@ mod tests {
|
|||||||
let unpacked = Sfnt::parse(&sfnt).unwrap();
|
let unpacked = Sfnt::parse(&sfnt).unwrap();
|
||||||
assert_eq!(original.tables.len(), unpacked.tables.len());
|
assert_eq!(original.tables.len(), unpacked.tables.len());
|
||||||
for (tag, _, _) in &original.tables {
|
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 mut damaged = woff.clone();
|
||||||
let len = damaged.len();
|
let len = damaged.len();
|
||||||
@@ -909,17 +1081,28 @@ mod tests {
|
|||||||
fn import_stores_content_addressed_and_round_trips() {
|
fn import_stores_content_addressed_and_round_trips() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let row =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
assert_eq!(row.face, "Gotham");
|
assert_eq!(row.face, "Gotham");
|
||||||
assert_eq!((row.weight, row.style.as_str(), row.format.as_str()), (400, "normal", "ttf"));
|
assert_eq!(
|
||||||
assert_eq!(row.file_name, "Jost-Regular.ttf", "only the base name is kept");
|
(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);
|
assert!(!row.hidden && row.licence_ack_at.len() > 10);
|
||||||
let path = dir.path().join("fonts").join(format!("{}.ttf", row.sha256));
|
let path = dir.path().join("fonts").join(format!("{}.ttf", row.sha256));
|
||||||
assert_eq!(std::fs::read(&path).unwrap(), regular());
|
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.
|
// Importing the same file again is a no-op.
|
||||||
let again = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let again =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
assert_eq!(again.id, row.id);
|
assert_eq!(again.id, row.id);
|
||||||
assert_eq!(list_user_fonts_impl(&conn, false).unwrap().len(), 1);
|
assert_eq!(list_user_fonts_impl(&conn, false).unwrap().len(), 1);
|
||||||
|
|
||||||
@@ -933,18 +1116,32 @@ mod tests {
|
|||||||
fn import_overrides_and_guards() {
|
fn import_overrides_and_guards() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", false))
|
assert!(
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", false))
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("licence"));
|
.contains("licence")
|
||||||
assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Montserrat", true))
|
);
|
||||||
|
assert!(import_font_impl(
|
||||||
|
&mut conn,
|
||||||
|
dir.path(),
|
||||||
|
®ular(),
|
||||||
|
&request("Montserrat", true)
|
||||||
|
)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("Now, Gotham, Open Sauce One"));
|
.contains("Now, Gotham, Open Sauce One"));
|
||||||
assert!(!dir.path().join("fonts").exists(), "a refused import writes nothing");
|
assert!(
|
||||||
|
!dir.path().join("fonts").exists(),
|
||||||
|
"a refused import writes nothing"
|
||||||
|
);
|
||||||
let mut woff2 = regular();
|
let mut woff2 = regular();
|
||||||
woff2[0..4].copy_from_slice(b"wOF2");
|
woff2[0..4].copy_from_slice(b"wOF2");
|
||||||
assert!(import_font_impl(&mut conn, dir.path(), &woff2, &request("Now", true)).is_err());
|
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(), ®ular(), &req).unwrap();
|
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &req).unwrap();
|
||||||
assert_eq!((row.weight, row.style.as_str()), (300, "italic"));
|
assert_eq!((row.weight, row.style.as_str()), (300, "italic"));
|
||||||
// The table itself refuses other faces.
|
// The table itself refuses other faces.
|
||||||
@@ -962,15 +1159,23 @@ mod tests {
|
|||||||
fn replacing_a_slot_removes_the_old_unreferenced_font() {
|
fn replacing_a_slot_removes_the_old_unreferenced_font() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let first = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let first =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
// Same slot, different file (the bold file declared as 400).
|
// 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();
|
let second = import_font_impl(&mut conn, dir.path(), &bold(), &req).unwrap();
|
||||||
assert_ne!(first.sha256, second.sha256);
|
assert_ne!(first.sha256, second.sha256);
|
||||||
let rows = list_user_fonts_impl(&conn, true).unwrap();
|
let rows = list_user_fonts_impl(&conn, true).unwrap();
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
assert_eq!(rows[0].sha256, second.sha256);
|
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) {
|
fn issue_invoice_with_prefs(conn: &Connection, prefs: &str) {
|
||||||
@@ -986,11 +1191,22 @@ mod tests {
|
|||||||
fn remove_deletes_when_unreferenced() {
|
fn remove_deletes_when_unreferenced() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
let row =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
||||||
let out = remove_user_font_impl(&mut conn, dir.path(), row.id).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!(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!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).is_err());
|
||||||
assert!(remove_user_font_impl(&mut conn, dir.path(), row.id).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() {
|
fn remove_hides_a_font_an_issued_invoice_references() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let row =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
issue_invoice_with_prefs(
|
issue_invoice_with_prefs(
|
||||||
&conn,
|
&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();
|
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.
|
// 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!(list_user_fonts_impl(&conn, false).unwrap().is_empty());
|
||||||
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 1);
|
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.
|
// 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_ne!(fresh.sha256, row.sha256);
|
||||||
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 2);
|
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() {
|
fn replacing_a_referenced_font_hides_it_instead_of_deleting() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let row =
|
||||||
issue_invoice_with_prefs(&conn, &format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256));
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap();
|
issue_invoice_with_prefs(
|
||||||
assert!(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists());
|
&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();
|
let all = list_user_fonts_impl(&conn, true).unwrap();
|
||||||
assert_eq!(all.len(), 2);
|
assert_eq!(all.len(), 2);
|
||||||
assert_eq!(all.iter().filter(|f| f.hidden).count(), 1);
|
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() {
|
fn a_file_shared_by_two_faces_survives_removing_one() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let a = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let a =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
let b = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
let b = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
||||||
assert_eq!(a.sha256, b.sha256);
|
assert_eq!(a.sha256, b.sha256);
|
||||||
remove_user_font_impl(&mut conn, dir.path(), a.id).unwrap();
|
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();
|
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]
|
#[test]
|
||||||
fn bytes_are_verified_and_the_hash_shape_checked() {
|
fn bytes_are_verified_and_the_hash_shape_checked() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
let row =
|
||||||
std::fs::write(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)), b"tampered").unwrap();
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
||||||
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap_err().contains("corrupted"));
|
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());
|
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-weight".into(), "700".into());
|
||||||
h.insert("x-style".into(), "italic".into());
|
h.insert("x-style".into(), "italic".into());
|
||||||
let req = parse_import_headers(&h, "f.ttf").unwrap();
|
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());
|
h.insert("x-style".into(), "oblique".into());
|
||||||
assert!(parse_import_headers(&h, "f.ttf").is_err());
|
assert!(parse_import_headers(&h, "f.ttf").is_err());
|
||||||
h.remove("x-licence-ack");
|
h.remove("x-licence-ack");
|
||||||
h.insert("x-style".into(), "".into());
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use super::assets::relative_asset_path;
|
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::series::{validate_series_format, MAX_NUMBER_LEN};
|
||||||
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
||||||
use crate::db::format_number;
|
use crate::db::format_number;
|
||||||
use crate::gst::{self, TaxType};
|
use crate::gst::{self, TaxType};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
|
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
|
||||||
|
Settings,
|
||||||
};
|
};
|
||||||
|
use crate::units;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use rusqlite::{named_params, params, Connection, OptionalExtension, Row};
|
use rusqlite::{named_params, params, Connection, OptionalExtension, Row};
|
||||||
@@ -59,6 +62,8 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
|||||||
archived_fingerprint: row.get(35)?,
|
archived_fingerprint: row.get(35)?,
|
||||||
archived_at: row.get(36)?,
|
archived_at: row.get(36)?,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
|
payment: PaymentSummary::default(),
|
||||||
|
total_paise: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +95,8 @@ fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
|
|||||||
map_invoice,
|
map_invoice,
|
||||||
)?;
|
)?;
|
||||||
invoice.items = fetch_items(conn, id)?;
|
invoice.items = fetch_items(conn, id)?;
|
||||||
|
invoice.total_paise = gst::rupees_to_paise(invoice.total);
|
||||||
|
invoice.payment = summary_for(conn, id, &invoice.status, invoice.total, &invoice.due_date)?;
|
||||||
Ok(invoice)
|
Ok(invoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +132,15 @@ fn parse_date(label: &str, value: &str) -> Result<(), String> {
|
|||||||
/// overflow integer paise arithmetic.
|
/// overflow integer paise arithmetic.
|
||||||
const MAX_LINE_RUPEES: f64 = 1e11;
|
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> {
|
fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Err("Add at least one line item".into());
|
return Err("Add at least one line item".into());
|
||||||
@@ -135,11 +151,14 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
|
|||||||
match item.mode.as_str() {
|
match item.mode.as_str() {
|
||||||
"rate" => {
|
"rate" => {
|
||||||
if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 {
|
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 {
|
if item.quantity > MAX_LINE_RUPEES {
|
||||||
return Err(format!("Line {n}: quantity is too large"));
|
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);
|
let amount = gst::line_amount_paise(item);
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
return Err(format!("Line {n}: amount must be greater than zero"));
|
return Err(format!("Line {n}: amount must be greater than zero"));
|
||||||
@@ -266,8 +285,9 @@ pub fn issue_invoice_impl(
|
|||||||
.map_err(db)?
|
.map_err(db)?
|
||||||
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
|
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
|
||||||
|
|
||||||
validate_series_format(&prefix, padding)
|
validate_series_format(&prefix, padding).map_err(|e| {
|
||||||
.map_err(|e| format!("The active invoice series cannot be used: {e}. Start a new series."))?;
|
format!("The active invoice series cannot be used: {e}. Start a new series.")
|
||||||
|
})?;
|
||||||
let number = format_number(&prefix, padding, next_number);
|
let number = format_number(&prefix, padding, next_number);
|
||||||
if number.len() > MAX_NUMBER_LEN {
|
if number.len() > MAX_NUMBER_LEN {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -291,10 +311,16 @@ pub fn issue_invoice_impl(
|
|||||||
};
|
};
|
||||||
let pos = input.place_of_supply_state_code.trim().to_string();
|
let pos = input.place_of_supply_state_code.trim().to_string();
|
||||||
if !pos.is_empty() && !gst::state_exists(&pos) {
|
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() {
|
if input.tax_type != derived.as_str() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}",
|
"Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}",
|
||||||
@@ -304,7 +330,11 @@ pub fn issue_invoice_impl(
|
|||||||
if pos.is_empty() { &settings.vendor_state_code } else { &pos },
|
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) {
|
if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) {
|
||||||
return Err("Tax rate must be between 0 and 100".into());
|
return Err("Tax rate must be between 0 and 100".into());
|
||||||
}
|
}
|
||||||
@@ -325,9 +355,9 @@ pub fn issue_invoice_impl(
|
|||||||
let snapshot = vendor_snapshot(&settings);
|
let snapshot = vendor_snapshot(&settings);
|
||||||
let bank = bank_snapshot(&tx, input.bank_account_id)?;
|
let bank = bank_snapshot(&tx, input.bank_account_id)?;
|
||||||
let signature_path = match input.signature_path.as_deref().map(str::trim) {
|
let signature_path = match input.signature_path.as_deref().map(str::trim) {
|
||||||
Some(p) if !p.is_empty() => Some(
|
Some(p) if !p.is_empty() => {
|
||||||
relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?,
|
Some(relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?)
|
||||||
),
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?;
|
let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?;
|
||||||
@@ -338,15 +368,16 @@ pub fn issue_invoice_impl(
|
|||||||
Some(id) => Some(id),
|
Some(id) => Some(id),
|
||||||
None if input.save_client && !input.client_name.trim().is_empty() => {
|
None if input.save_client && !input.client_name.trim().is_empty() => {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at, gst_category)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
params![
|
params![
|
||||||
input.client_name,
|
input.client_name,
|
||||||
input.client_address,
|
input.client_address,
|
||||||
client_gstin,
|
client_gstin,
|
||||||
stored_pos,
|
stored_pos,
|
||||||
input.po_number,
|
input.po_number,
|
||||||
now
|
now,
|
||||||
|
if client_gstin.is_empty() { "unregistered" } else { "registered_regular" }
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
@@ -421,7 +452,7 @@ pub fn issue_invoice_impl(
|
|||||||
item.description,
|
item.description,
|
||||||
item.mode,
|
item.mode,
|
||||||
item.rate,
|
item.rate,
|
||||||
item.unit,
|
stored_unit(item),
|
||||||
item.quantity,
|
item.quantity,
|
||||||
paise_to_rupees(gst::line_amount_paise(item)),
|
paise_to_rupees(gst::line_amount_paise(item)),
|
||||||
i as i64,
|
i as i64,
|
||||||
@@ -439,7 +470,10 @@ pub fn issue_invoice_impl(
|
|||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
|
|
||||||
if let Some(draft_id) = input.draft_id {
|
if let Some(draft_id) = input.draft_id {
|
||||||
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id])
|
tx.execute(
|
||||||
|
"DELETE FROM invoice_drafts WHERE id = ?1",
|
||||||
|
params![draft_id],
|
||||||
|
)
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +492,21 @@ pub fn issue_invoice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
/// 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),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if payments > 0 {
|
||||||
|
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
|
||||||
|
}
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
let changed = conn
|
let changed = conn
|
||||||
.execute(
|
.execute(
|
||||||
@@ -470,7 +518,11 @@ pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Resu
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if changed == 0 {
|
if changed == 0 {
|
||||||
let status: Option<String> = conn
|
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()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
return Err(match status {
|
return Err(match status {
|
||||||
@@ -490,23 +542,49 @@ pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
|
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_invoices_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One query: invoices left-joined to their payment sums. Status derivation happens in Rust
|
||||||
|
/// (`derive_summary`) so list and detail agree.
|
||||||
|
pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, String> {
|
||||||
|
let today = today_ist();
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, number, invoice_date, due_date, client_name, total, status, created_at
|
"SELECT i.id, i.number, i.invoice_date, i.due_date, i.client_name, i.total, i.status,
|
||||||
FROM invoices ORDER BY id DESC",
|
i.created_at, i.po_number, COALESCE(p.paid, 0), COALESCE(p.tds, 0)
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN (SELECT invoice_id, SUM(amount_paise) AS paid, SUM(tds_paise) AS tds
|
||||||
|
FROM payments GROUP BY invoice_id) p ON p.invoice_id = i.id
|
||||||
|
ORDER BY i.id DESC",
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
|
let total: f64 = row.get(5)?;
|
||||||
|
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,
|
||||||
|
);
|
||||||
Ok(InvoiceSummary {
|
Ok(InvoiceSummary {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
number: row.get(1)?,
|
number: row.get(1)?,
|
||||||
invoice_date: row.get(2)?,
|
invoice_date: row.get(2)?,
|
||||||
due_date: row.get(3)?,
|
due_date,
|
||||||
client_name: row.get(4)?,
|
client_name: row.get(4)?,
|
||||||
total: row.get(5)?,
|
total,
|
||||||
status: row.get(6)?,
|
status,
|
||||||
created_at: row.get(7)?,
|
created_at: row.get(7)?,
|
||||||
|
po_number: row.get(8)?,
|
||||||
|
total_paise,
|
||||||
|
payment,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
@@ -515,10 +593,92 @@ pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, Stri
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
|
||||||
|
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InvoiceLedgerRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub client_gstin: String,
|
||||||
|
pub place_of_supply_code: String,
|
||||||
|
pub doc_type: String,
|
||||||
|
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
|
||||||
|
pub hsn_sac: Vec<String>,
|
||||||
|
/// Subtotal less discount.
|
||||||
|
pub taxable_paise: i64,
|
||||||
|
pub cgst_paise: i64,
|
||||||
|
pub sgst_paise: i64,
|
||||||
|
pub igst_paise: i64,
|
||||||
|
pub reverse_charge: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 stmt = conn
|
||||||
|
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let items = stmt
|
||||||
|
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
for item in items {
|
||||||
|
let (id, code) = item.map_err(|e| e.to_string())?;
|
||||||
|
let list = hsn_by_invoice.entry(id).or_default();
|
||||||
|
if !code.is_empty() && !list.contains(&code) {
|
||||||
|
list.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
|
||||||
|
cgst_amount, sgst_amount, igst_amount, reverse_charge
|
||||||
|
FROM invoices ORDER BY id DESC",
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
let id: i64 = r.get(0)?;
|
||||||
|
Ok(InvoiceLedgerRow {
|
||||||
|
id,
|
||||||
|
client_gstin: r.get(1)?,
|
||||||
|
place_of_supply_code: r.get(2)?,
|
||||||
|
doc_type: r.get(3)?,
|
||||||
|
hsn_sac: Vec::new(),
|
||||||
|
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
|
||||||
|
cgst_paise: gst::rupees_to_paise(r.get(6)?),
|
||||||
|
sgst_paise: gst::rupees_to_paise(r.get(7)?),
|
||||||
|
igst_paise: gst::rupees_to_paise(r.get(8)?),
|
||||||
|
reverse_charge: r.get::<_, i64>(9)? != 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut row| {
|
||||||
|
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_invoice_ledger_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
|
||||||
|
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
fetch_invoice(&conn, id).map_err(|e| e.to_string())
|
get_invoice_impl(&conn, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_draft_impl(
|
pub fn save_draft_impl(
|
||||||
@@ -554,11 +714,17 @@ pub fn save_draft_impl(
|
|||||||
|
|
||||||
pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> {
|
pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> {
|
||||||
let mut stmt = conn
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |r| {
|
.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())?
|
.map_err(|e| e.to_string())?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
@@ -570,7 +736,10 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
|
|||||||
DraftSummary {
|
DraftSummary {
|
||||||
id,
|
id,
|
||||||
updated_at,
|
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()),
|
total: json.get("total").and_then(|v| v.as_f64()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -579,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> {
|
pub fn get_draft_impl(conn: &Connection, id: i64) -> Result<serde_json::Value, String> {
|
||||||
let text: String = conn
|
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()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.ok_or_else(|| "Draft not found".to_string())?;
|
.ok_or_else(|| "Draft not found".to_string())?;
|
||||||
@@ -653,12 +826,46 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn next_number(conn: &Connection) -> i64 {
|
fn next_number(conn: &Connection) -> i64 {
|
||||||
conn.query_row("SELECT next_number FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
conn.query_row(
|
||||||
|
"SELECT next_number FROM invoice_series WHERE is_active = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invoice_count(conn: &Connection) -> i64 {
|
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(
|
||||||
|
"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],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ledger = list_invoice_ledger_impl(&conn).unwrap();
|
||||||
|
assert_eq!(ledger.len(), 1);
|
||||||
|
let row = &ledger[0];
|
||||||
|
assert_eq!(row.id, first.id);
|
||||||
|
assert_eq!(row.place_of_supply_code, "27");
|
||||||
|
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!(!row.reverse_charge);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -720,6 +927,43 @@ mod tests {
|
|||||||
assert_eq!(inv.items[1].hsn_sac, "");
|
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]
|
#[test]
|
||||||
fn tax_type_mismatch_is_rejected_and_the_counter_stays() {
|
fn tax_type_mismatch_is_rejected_and_the_counter_stays() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
@@ -728,7 +972,10 @@ mod tests {
|
|||||||
input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})),
|
input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.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();
|
let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err();
|
||||||
assert!(err.contains("does not match"), "{err}");
|
assert!(err.contains("does not match"), "{err}");
|
||||||
assert_eq!(next_number(&conn), 1);
|
assert_eq!(next_number(&conn), 1);
|
||||||
@@ -738,7 +985,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unregistered_supplier_issues_a_plain_invoice_without_tax() {
|
fn unregistered_supplier_issues_a_plain_invoice_without_tax() {
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
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(
|
let inv = issue(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})),
|
input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})),
|
||||||
@@ -795,7 +1046,8 @@ mod tests {
|
|||||||
assert!(inv.amount_in_words.contains("Eight Thousand"));
|
assert!(inv.amount_in_words.contains("Eight Thousand"));
|
||||||
|
|
||||||
// Later settings edits do not touch the stored snapshot.
|
// 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();
|
let again = fetch_invoice(&conn, inv.id).unwrap();
|
||||||
assert_eq!(again.vendor_snapshot, inv.vendor_snapshot);
|
assert_eq!(again.vendor_snapshot, inv.vendor_snapshot);
|
||||||
}
|
}
|
||||||
@@ -803,10 +1055,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn bank_snapshot_is_built_from_the_bank_row() {
|
fn bank_snapshot_is_built_from_the_bank_row() {
|
||||||
let mut conn = registered();
|
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 inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
|
||||||
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).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();
|
let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err();
|
||||||
assert!(err.contains("bank account"), "{err}");
|
assert!(err.contains("bank account"), "{err}");
|
||||||
}
|
}
|
||||||
@@ -822,15 +1076,22 @@ mod tests {
|
|||||||
params![format!("INV/{year}-001")],
|
params![format!("INV/{year}-001")],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
conn.execute("INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", [])
|
conn.execute(
|
||||||
|
"INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err();
|
let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err();
|
||||||
assert!(err.contains("already exists"), "{err}");
|
assert!(err.contains("already exists"), "{err}");
|
||||||
assert_eq!(next_number(&conn), 1);
|
assert_eq!(next_number(&conn), 1);
|
||||||
assert_eq!(invoice_count(&conn), 1);
|
assert_eq!(invoice_count(&conn), 1);
|
||||||
// The client insert and the draft delete were rolled back too.
|
// 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 clients: i64 = conn
|
||||||
let drafts: i64 = conn.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0)).unwrap();
|
.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));
|
assert_eq!((clients, drafts), (0, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,10 +1106,19 @@ mod tests {
|
|||||||
json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}),
|
json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}),
|
||||||
"greater than zero",
|
"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!({"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!({"taxRate": 150.0}), "between 0 and 100"),
|
||||||
(json!({"signaturePath": "../voiced.db"}), "Signature image"),
|
(json!({"signaturePath": "../voiced.db"}), "Signature image"),
|
||||||
];
|
];
|
||||||
@@ -867,31 +1137,58 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn supplier_settings_are_validated() {
|
fn supplier_settings_are_validated() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'", []).unwrap();
|
conn.execute(
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("check digit"));
|
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'",
|
||||||
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();
|
.unwrap();
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code 29"));
|
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(
|
conn.execute(
|
||||||
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'",
|
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("PAN"));
|
assert!(issue(&mut conn, input(json!({})))
|
||||||
conn.execute("UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'", [])
|
.unwrap_err()
|
||||||
|
.contains("PAN"));
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code"));
|
assert!(issue(&mut conn, input(json!({})))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("state code"));
|
||||||
assert_eq!(next_number(&conn), 1);
|
assert_eq!(next_number(&conn), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_series_that_break_the_rules_are_refused() {
|
fn legacy_series_that_break_the_rules_are_refused() {
|
||||||
let mut conn = registered();
|
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();
|
let err = issue(&mut conn, input(json!({}))).unwrap_err();
|
||||||
assert!(err.contains("active invoice series"), "{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());
|
assert!(issue(&mut conn, input(json!({}))).is_err());
|
||||||
// A counter that outgrows its padding can push the number past 16 characters.
|
// A counter that outgrows its padding can push the number past 16 characters.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -899,7 +1196,9 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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);
|
assert_eq!(invoice_count(&conn), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +1226,10 @@ mod tests {
|
|||||||
&json!({"theme": "plain"}),
|
&json!({"theme": "plain"}),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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
|
let prefs: String = conn
|
||||||
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -948,8 +1250,14 @@ mod tests {
|
|||||||
|
|
||||||
let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err();
|
let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err();
|
||||||
assert!(err.contains("cancelled"), "{err}");
|
assert!(err.contains("cancelled"), "{err}");
|
||||||
assert!(cancel_invoice_impl(&mut conn, 999, "").unwrap_err().contains("not found"));
|
assert!(cancel_invoice_impl(&mut conn, 999, "")
|
||||||
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![inv.id]).unwrap();
|
.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());
|
assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err());
|
||||||
|
|
||||||
// The number is never reused.
|
// The number is never reused.
|
||||||
@@ -961,10 +1269,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn drafts_round_trip() {
|
fn drafts_round_trip() {
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
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();
|
let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap();
|
||||||
assert_ne!(a, b);
|
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_eq!(c, a);
|
||||||
assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err());
|
assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err());
|
||||||
|
|
||||||
@@ -977,7 +1291,8 @@ mod tests {
|
|||||||
let blank = list.iter().find(|d| d.id == b).unwrap();
|
let blank = list.iter().find(|d| d.id == b).unwrap();
|
||||||
assert_eq!((blank.client_name.clone(), blank.total), (None, None));
|
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());
|
assert!(get_draft_impl(&conn, a).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,22 @@ pub struct LogoAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sha8(bytes: &[u8]) -> String {
|
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
|
/// 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
|
/// 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
|
/// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at
|
||||||
/// older logo files.
|
/// 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 derived = process_logo(bytes).map_err(|e| e.to_string())?;
|
||||||
let id = sha8(bytes);
|
let id = sha8(bytes);
|
||||||
let original_path = match existing_original {
|
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)?
|
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
|
let knockout_path = derived
|
||||||
.knockout_png
|
.knockout_png
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png))
|
.map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png))
|
||||||
.transpose()?;
|
.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> {
|
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 {
|
if len > MAX_INPUT_BYTES as u64 {
|
||||||
return Err(crate::logo::LogoError::TooLarge.to_string());
|
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 {
|
if data_base64.len() > MAX_INPUT_BYTES / 3 * 4 + 8 {
|
||||||
return Err(crate::logo::LogoError::TooLarge.to_string());
|
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)
|
store_logo(data_dir, &bytes, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +95,11 @@ fn metrics_json(metrics: &LogoMetrics) -> Result<String, String> {
|
|||||||
/// original. Returns whether anything changed.
|
/// original. Returns whether anything changed.
|
||||||
pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bool, String> {
|
pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bool, String> {
|
||||||
let (logo_path, meta): (Option<String>, String) = conn
|
let (logo_path, meta): (Option<String>, String) = conn
|
||||||
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
"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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else {
|
let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else {
|
||||||
return Ok(false);
|
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 file = resolve_asset(data_dir, &logo_path)?;
|
||||||
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
|
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(
|
conn.execute(
|
||||||
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
|
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
|
||||||
logo_meta = ?4 WHERE id = 1",
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -161,7 +193,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut out = Vec::new();
|
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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,16 +214,30 @@ mod tests {
|
|||||||
let bytes = padded_png();
|
let bytes = padded_png();
|
||||||
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap();
|
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap();
|
||||||
let id = sha8(&bytes);
|
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.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!((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()] {
|
for rel in [&asset.print_path, asset.knockout_path.as_ref().unwrap()] {
|
||||||
assert!(resolve_asset(dir.path(), rel).is_ok());
|
assert!(resolve_asset(dir.path(), rel).is_ok());
|
||||||
}
|
}
|
||||||
// The same bytes land on the same names.
|
// 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();
|
let json = serde_json::to_value(&asset).unwrap();
|
||||||
assert_eq!(json["metrics"]["kind"], "wordmark");
|
assert_eq!(json["metrics"]["kind"], "wordmark");
|
||||||
assert_eq!(json["metrics"]["opaqueBackground"], false);
|
assert_eq!(json["metrics"]["opaqueBackground"], false);
|
||||||
@@ -220,7 +268,8 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
let legacy = write_named_asset(dir.path(), "logo-legacy.png", &padded_png()).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());
|
assert!(ensure_logo_derived_impl(&conn, dir.path()).unwrap());
|
||||||
let (logo, original, knockout, meta) = logo_columns(&conn);
|
let (logo, original, knockout, meta) = logo_columns(&conn);
|
||||||
@@ -243,7 +292,8 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
assert!(!ensure_logo_derived_impl(&conn, dir.path()).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());
|
assert!(ensure_logo_derived_impl(&conn, dir.path()).is_err());
|
||||||
// Left untouched so a later start can retry.
|
// Left untouched so a later start can retry.
|
||||||
assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png"));
|
assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png"));
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
pub mod archive;
|
pub mod archive;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
pub mod backup;
|
||||||
pub mod clients;
|
pub mod clients;
|
||||||
|
pub mod erpnext;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod fonts;
|
pub mod fonts;
|
||||||
pub mod invoice;
|
pub mod invoice;
|
||||||
pub mod logo;
|
pub mod logo;
|
||||||
|
pub mod payments;
|
||||||
|
pub mod presets;
|
||||||
pub mod raw;
|
pub mod raw;
|
||||||
|
pub mod reset;
|
||||||
pub mod series;
|
pub mod series;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
//! Payments received against issued invoices.
|
||||||
|
//!
|
||||||
|
//! Money is integer paise throughout. Payment status is never stored: it is derived from the
|
||||||
|
//! payment rows, the invoice total and the due date, so deleting a payment reverts it for free.
|
||||||
|
//! The issued invoice row is not touched by any of this.
|
||||||
|
|
||||||
|
use crate::gst::rupees_to_paise;
|
||||||
|
use crate::models::PaymentSummary;
|
||||||
|
use crate::AppState;
|
||||||
|
use chrono::{FixedOffset, NaiveDate, Utc};
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
const MODES: &[&str] = &["bank_transfer", "upi", "cash", "cheque", "other"];
|
||||||
|
/// Upper bound on one payment (paise); keeps sums far from i64 overflow.
|
||||||
|
const MAX_PAISE: i64 = 100_000_000_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Payment {
|
||||||
|
pub id: i64,
|
||||||
|
pub invoice_id: i64,
|
||||||
|
pub paid_on: String,
|
||||||
|
/// Cash received.
|
||||||
|
pub amount_paise: i64,
|
||||||
|
/// Tax deducted at source by the client; settles the invoice like cash does.
|
||||||
|
pub tds_paise: i64,
|
||||||
|
pub mode: String,
|
||||||
|
pub reference: String,
|
||||||
|
pub notes: String,
|
||||||
|
pub created_at: String,
|
||||||
|
/// Name of the Payment Entry on ERPNext once this payment was sent; `None` until then.
|
||||||
|
#[serde(default)]
|
||||||
|
pub erpnext_payment_entry: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PaymentInput {
|
||||||
|
pub invoice_id: i64,
|
||||||
|
pub paid_on: String,
|
||||||
|
pub amount_paise: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tds_paise: i64,
|
||||||
|
pub mode: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reference: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
|
||||||
|
/// with a zero balance (a cancelled invoice is owed nothing). `settled` is cash plus TDS.
|
||||||
|
/// Overdue means a due date strictly before `today` while a balance remains; it wins over
|
||||||
|
/// `partially_paid`, and the paid amounts still show the partial payment.
|
||||||
|
pub fn derive_summary(
|
||||||
|
status: &str,
|
||||||
|
total_paise: i64,
|
||||||
|
paid_paise: i64,
|
||||||
|
tds_paise: i64,
|
||||||
|
due_date: &str,
|
||||||
|
today: &str,
|
||||||
|
) -> PaymentSummary {
|
||||||
|
if status != "issued" {
|
||||||
|
return PaymentSummary {
|
||||||
|
paid_paise,
|
||||||
|
tds_paise,
|
||||||
|
balance_paise: 0,
|
||||||
|
payment_status: "none".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let settled = paid_paise + tds_paise;
|
||||||
|
let balance = (total_paise - settled).max(0);
|
||||||
|
let overdue = balance > 0
|
||||||
|
&& NaiveDate::parse_from_str(due_date.trim(), "%Y-%m-%d").is_ok()
|
||||||
|
&& due_date.trim() < today;
|
||||||
|
let payment_status = if balance == 0 && total_paise > 0 {
|
||||||
|
"paid"
|
||||||
|
} else if overdue {
|
||||||
|
"overdue"
|
||||||
|
} else if settled > 0 {
|
||||||
|
"partially_paid"
|
||||||
|
} else {
|
||||||
|
"unpaid"
|
||||||
|
};
|
||||||
|
PaymentSummary {
|
||||||
|
paid_paise,
|
||||||
|
tds_paise,
|
||||||
|
balance_paise: balance,
|
||||||
|
payment_status: payment_status.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary for one invoice read from the payments table.
|
||||||
|
pub fn summary_for(
|
||||||
|
conn: &Connection,
|
||||||
|
invoice_id: i64,
|
||||||
|
status: &str,
|
||||||
|
total: f64,
|
||||||
|
due_date: &str,
|
||||||
|
) -> rusqlite::Result<PaymentSummary> {
|
||||||
|
let (paid, tds): (i64, i64) = conn.query_row(
|
||||||
|
"SELECT COALESCE(SUM(amount_paise), 0), COALESCE(SUM(tds_paise), 0)
|
||||||
|
FROM payments WHERE invoice_id = ?1",
|
||||||
|
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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rupees(paise: i64) -> String {
|
||||||
|
format!("₹{}.{:02}", paise / 100, paise % 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
|
||||||
|
Ok(Payment {
|
||||||
|
id: r.get(0)?,
|
||||||
|
invoice_id: r.get(1)?,
|
||||||
|
paid_on: r.get(2)?,
|
||||||
|
amount_paise: r.get(3)?,
|
||||||
|
tds_paise: r.get(4)?,
|
||||||
|
mode: r.get(5)?,
|
||||||
|
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()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLS: &str = "id, invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at, erpnext_payment_entry";
|
||||||
|
|
||||||
|
pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result<Payment, String> {
|
||||||
|
let db = |e: rusqlite::Error| e.to_string();
|
||||||
|
|
||||||
|
NaiveDate::parse_from_str(input.paid_on.trim(), "%Y-%m-%d")
|
||||||
|
.map_err(|_| "Payment date must be a date like 2026-04-01".to_string())?;
|
||||||
|
if !MODES.contains(&input.mode.as_str()) {
|
||||||
|
return Err(format!("Unknown payment mode \"{}\"", input.mode));
|
||||||
|
}
|
||||||
|
if input.amount_paise < 0 || input.tds_paise < 0 {
|
||||||
|
return Err("Amounts cannot be negative".into());
|
||||||
|
}
|
||||||
|
if input.amount_paise > MAX_PAISE || input.tds_paise > MAX_PAISE {
|
||||||
|
return Err("That amount is too large".into());
|
||||||
|
}
|
||||||
|
if input.amount_paise + input.tds_paise == 0 {
|
||||||
|
return Err("Enter an amount received or TDS deducted".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx = conn.transaction().map_err(db)?;
|
||||||
|
let (status, total): (String, f64) = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT status, total FROM invoices WHERE id = ?1",
|
||||||
|
params![input.invoice_id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.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})"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let total_paise = rupees_to_paise(total);
|
||||||
|
let settled: i64 = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE(SUM(amount_paise + tds_paise), 0) FROM payments WHERE invoice_id = ?1",
|
||||||
|
params![input.invoice_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.map_err(db)?;
|
||||||
|
let balance = total_paise - settled;
|
||||||
|
let incoming = input.amount_paise + input.tds_paise;
|
||||||
|
if incoming > balance {
|
||||||
|
return Err(format!(
|
||||||
|
"This payment of {} (cash plus TDS) is more than the outstanding balance of {}",
|
||||||
|
rupees(incoming),
|
||||||
|
rupees(balance.max(0))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
params![
|
||||||
|
input.invoice_id,
|
||||||
|
input.paid_on.trim(),
|
||||||
|
input.amount_paise,
|
||||||
|
input.tds_paise,
|
||||||
|
input.mode,
|
||||||
|
input.reference.trim(),
|
||||||
|
input.notes.trim(),
|
||||||
|
now
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.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,
|
||||||
|
)
|
||||||
|
.map_err(db)?;
|
||||||
|
tx.commit().map_err(db)?;
|
||||||
|
Ok(payment)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_payments_impl(conn: &Connection, invoice_id: i64) -> Result<Vec<Payment>, String> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&format!(
|
||||||
|
"SELECT {COLS} FROM payments WHERE invoice_id = ?1 ORDER BY paid_on ASC, id ASC"
|
||||||
|
))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![invoice_id], map_payment)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_payment_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||||
|
let changed = conn
|
||||||
|
.execute("DELETE FROM payments WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if changed == 0 {
|
||||||
|
return Err("Payment not found".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn record_payment(state: State<AppState>, input: PaymentInput) -> Result<Payment, String> {
|
||||||
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
record_payment_impl(&mut conn, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_payments(state: State<AppState>, invoice_id: i64) -> Result<Vec<Payment>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_payments_impl(&conn, invoice_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_payment(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
delete_payment_impl(&conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::commands::invoice::{cancel_invoice_impl, issue_invoice_impl};
|
||||||
|
use crate::models::InvoiceInput;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// An unregistered vendor issues without tax, so the invoice total is exactly the line amount.
|
||||||
|
fn issued(conn: &mut Connection, amount: f64, due: &str) -> i64 {
|
||||||
|
let input: InvoiceInput = serde_json::from_value(json!({
|
||||||
|
"invoiceDate": "2026-04-01",
|
||||||
|
"dueDate": due,
|
||||||
|
"clientName": "Client Ltd",
|
||||||
|
"taxType": "none",
|
||||||
|
"taxRate": 0.0,
|
||||||
|
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({}))
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
|
||||||
|
PaymentInput {
|
||||||
|
invoice_id,
|
||||||
|
paid_on: "2026-04-10".into(),
|
||||||
|
amount_paise: amount,
|
||||||
|
tds_paise: tds,
|
||||||
|
mode: "bank_transfer".into(),
|
||||||
|
reference: "UTR1".into(),
|
||||||
|
notes: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
summary_for(conn, id, &status, total, &due).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_then_full_payment_settles() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
assert_eq!(summary(&conn, id).payment_status, "unpaid");
|
||||||
|
assert_eq!(summary(&conn, id).balance_paise, 100_000);
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
|
||||||
|
let s = summary(&conn, id);
|
||||||
|
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("paid", 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tds_counts_towards_settling() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
// 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)
|
||||||
|
);
|
||||||
|
// 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();
|
||||||
|
assert_eq!(summary(&conn, id2).payment_status, "partially_paid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overpayment_is_rejected_including_tds() {
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
// 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();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_input_is_rejected() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(id, 0, 0)).is_err());
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(id, -1, 0)).is_err());
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(id, 100, -5)).is_err());
|
||||||
|
let mut bad = pay(id, 100, 0);
|
||||||
|
bad.mode = "barter".into();
|
||||||
|
assert!(record_payment_impl(&mut conn, bad).is_err());
|
||||||
|
let mut bad = pay(id, 100, 0);
|
||||||
|
bad.paid_on = "10/04/2026".into();
|
||||||
|
assert!(record_payment_impl(&mut conn, bad).is_err());
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(9999, 100, 0)).is_err());
|
||||||
|
// The table CHECKs hold even if a caller bypasses the command validation.
|
||||||
|
assert!(conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, created_at)
|
||||||
|
VALUES (?1, '2026-04-10', 0, 0, 'cash', 'now')",
|
||||||
|
params![id]
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_and_cancelled_invoices_take_no_payments() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
cancel_invoice_impl(&mut conn, id, "mistake").unwrap();
|
||||||
|
let err = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap_err();
|
||||||
|
assert!(err.contains("issued invoice"), "{err}");
|
||||||
|
let s = summary(&conn, id);
|
||||||
|
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();
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invoice_with_payments_cannot_be_cancelled() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
let p = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap();
|
||||||
|
let err = cancel_invoice_impl(&mut conn, id, "").unwrap_err();
|
||||||
|
assert!(err.contains("payments"), "{err}");
|
||||||
|
delete_payment_impl(&conn, p.id).unwrap();
|
||||||
|
cancel_invoice_impl(&mut conn, id, "").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_payment_reverts_the_status() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
let full = record_payment_impl(&mut conn, pay(id, 100_000, 0)).unwrap();
|
||||||
|
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!(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")
|
||||||
|
};
|
||||||
|
// 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)
|
||||||
|
);
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totals_are_integer_paise_without_float_drift() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
// 0.1 + 0.2 style amounts: 3 x 33.33 = 99.99.
|
||||||
|
let id = issued(&mut conn, 99.99, "2999-01-01");
|
||||||
|
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
|
||||||
|
assert_eq!(inv.total_paise, 9999);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_invoices_joins_the_payment_sums() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let a = issued(&mut conn, 1000.0, "2020-01-01");
|
||||||
|
let b = issued(&mut conn, 200.0, "2999-01-01");
|
||||||
|
record_payment_impl(&mut conn, pay(a, 30_000, 5_000)).unwrap();
|
||||||
|
record_payment_impl(&mut conn, pay(a, 10_000, 0)).unwrap();
|
||||||
|
record_payment_impl(&mut conn, pay(b, 20_000, 0)).unwrap();
|
||||||
|
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.payment_status, "overdue");
|
||||||
|
assert_eq!(ra.total_paise, 100_000);
|
||||||
|
assert_eq!(get(b).payment.payment_status, "paid");
|
||||||
|
// The JSON keeps the existing fields and adds the flattened summary.
|
||||||
|
let json = serde_json::to_value(ra).unwrap();
|
||||||
|
assert_eq!(json["balancePaise"], 55_000);
|
||||||
|
assert_eq!(json["paymentStatus"], "overdue");
|
||||||
|
assert_eq!(json["total"], 1000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
use crate::models::ItemPreset;
|
||||||
|
use crate::units;
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Connection, Row};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
const MODES: &[&str] = &["fixed", "rate"];
|
||||||
|
/// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow.
|
||||||
|
const MAX_RATE_PAISE: i64 = 100_000_000_000;
|
||||||
|
|
||||||
|
const COLS: &str = "id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at";
|
||||||
|
|
||||||
|
fn map_preset(row: &Row) -> rusqlite::Result<ItemPreset> {
|
||||||
|
Ok(ItemPreset {
|
||||||
|
id: Some(row.get(0)?),
|
||||||
|
description: row.get(1)?,
|
||||||
|
hsn_sac: row.get(2)?,
|
||||||
|
mode: row.get(3)?,
|
||||||
|
unit: row.get(4)?,
|
||||||
|
rate_paise: row.get(5)?,
|
||||||
|
sort_order: row.get(6)?,
|
||||||
|
created_at: row.get(7)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_presets_impl(conn: &Connection) -> Result<Vec<ItemPreset>, String> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&format!(
|
||||||
|
"SELECT {COLS} FROM item_presets ORDER BY sort_order ASC, description COLLATE NOCASE ASC, id ASC"
|
||||||
|
))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], map_preset)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPreset, String> {
|
||||||
|
let description = preset.description.trim().to_string();
|
||||||
|
if description.is_empty() {
|
||||||
|
return Err("Preset description is required".into());
|
||||||
|
}
|
||||||
|
if !MODES.contains(&preset.mode.as_str()) {
|
||||||
|
return Err(format!("Unknown line type \"{}\"", preset.mode));
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
let hsn = preset.hsn_sac.trim().to_string();
|
||||||
|
let id = match preset.id {
|
||||||
|
Some(id) => {
|
||||||
|
let n = conn
|
||||||
|
.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, unit, preset.rate_paise, preset.sort_order, id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err("Preset not found".into());
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
params![
|
||||||
|
description,
|
||||||
|
hsn,
|
||||||
|
preset.mode,
|
||||||
|
unit,
|
||||||
|
preset.rate_paise,
|
||||||
|
preset.sort_order,
|
||||||
|
chrono::Utc::now().to_rfc3339()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
conn.last_insert_rowid()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
conn.query_row(
|
||||||
|
&format!("SELECT {COLS} FROM item_presets WHERE id = ?1"),
|
||||||
|
params![id],
|
||||||
|
map_preset,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_preset_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||||
|
conn.execute("DELETE FROM item_presets WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_item_presets(state: State<AppState>) -> Result<Vec<ItemPreset>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_presets_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_item_preset(state: State<AppState>, preset: ItemPreset) -> Result<ItemPreset, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
save_preset_impl(&conn, preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_item_preset(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
delete_preset_impl(&conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::open_in_memory;
|
||||||
|
|
||||||
|
fn preset(desc: &str) -> ItemPreset {
|
||||||
|
serde_json::from_value(serde_json::json!({ "id": null, "description": desc })).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crud_round_trip_in_integer_paise() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let mut p = preset(" Voice-over session ");
|
||||||
|
p.mode = "rate".into();
|
||||||
|
p.unit = "hour".into();
|
||||||
|
p.rate_paise = 150_050;
|
||||||
|
p.hsn_sac = " 998397 ".into();
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut edit = saved.clone();
|
||||||
|
edit.rate_paise = 200_000;
|
||||||
|
let again = save_preset_impl(&conn, edit).unwrap();
|
||||||
|
assert_eq!(again.id, saved.id);
|
||||||
|
assert_eq!(again.rate_paise, 200_000);
|
||||||
|
|
||||||
|
save_preset_impl(&conn, preset("Another")).unwrap();
|
||||||
|
let listed = list_presets_impl(&conn).unwrap();
|
||||||
|
assert_eq!(listed.len(), 2);
|
||||||
|
// Same sort_order: alphabetical.
|
||||||
|
assert_eq!(listed[0].description, "Another");
|
||||||
|
|
||||||
|
delete_preset_impl(&conn, saved.id.unwrap()).unwrap();
|
||||||
|
assert_eq!(list_presets_impl(&conn).unwrap().len(), 1);
|
||||||
|
// Deleting a missing id is a no-op.
|
||||||
|
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();
|
||||||
|
assert!(save_preset_impl(&conn, preset(" ")).is_err());
|
||||||
|
let mut p = preset("X");
|
||||||
|
p.rate_paise = -1;
|
||||||
|
assert!(save_preset_impl(&conn, p).is_err());
|
||||||
|
let mut p = preset("X");
|
||||||
|
p.mode = "weird".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());
|
||||||
|
assert!(list_presets_impl(&conn).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,7 +154,11 @@ mod tests {
|
|||||||
assert_eq!(s.prefix, "FY27/");
|
assert_eq!(s.prefix, "FY27/");
|
||||||
assert_eq!(s.next_invoice_number, "FY27/0001");
|
assert_eq!(s.next_invoice_number, "FY27/0001");
|
||||||
let active: i64 = c
|
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();
|
.unwrap();
|
||||||
assert_eq!(active, 1);
|
assert_eq!(active, 1);
|
||||||
}
|
}
|
||||||
@@ -163,12 +167,20 @@ mod tests {
|
|||||||
fn invalid_series_leaves_the_active_one_alone() {
|
fn invalid_series_leaves_the_active_one_alone() {
|
||||||
let mut c = conn();
|
let mut c = conn();
|
||||||
let before: i64 = c
|
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();
|
.unwrap();
|
||||||
assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err());
|
assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err());
|
||||||
assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err());
|
assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err());
|
||||||
let after: i64 = c
|
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();
|
.unwrap();
|
||||||
assert_eq!(before, after);
|
assert_eq!(before, after);
|
||||||
}
|
}
|
||||||
@@ -184,7 +196,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
|
assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
|
||||||
let active: i64 = c
|
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();
|
.unwrap();
|
||||||
assert_eq!(active, 1);
|
assert_eq!(active, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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"#,
|
account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#,
|
||||||
params![
|
params![
|
||||||
bank.label, bank.bank_name, bank.account_name, bank.account_no,
|
bank.label,
|
||||||
bank.branch, bank.ifsc, is_default, id
|
bank.bank_name,
|
||||||
|
bank.account_name,
|
||||||
|
bank.account_no,
|
||||||
|
bank.branch,
|
||||||
|
bank.ifsc,
|
||||||
|
is_default,
|
||||||
|
id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.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)
|
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
|
||||||
params![
|
params![
|
||||||
bank.label, bank.bank_name, bank.account_name, bank.account_no,
|
bank.label,
|
||||||
bank.branch, bank.ifsc, is_default
|
bank.bank_name,
|
||||||
|
bank.account_name,
|
||||||
|
bank.account_no,
|
||||||
|
bank.branch,
|
||||||
|
bank.ifsc,
|
||||||
|
is_default
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -208,10 +219,16 @@ pub fn delete_bank_impl(conn: &mut Connection, id: i64) -> Result<(), String> {
|
|||||||
tx.execute("UPDATE bank_accounts SET is_default = 0", [])
|
tx.execute("UPDATE bank_accounts SET is_default = 0", [])
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if let Some(next) = next {
|
if let Some(next) = next {
|
||||||
tx.execute("UPDATE bank_accounts SET is_default = 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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
tx.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![next])
|
tx.execute(
|
||||||
|
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
||||||
|
params![next],
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
tx.commit().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>) {
|
fn default_state(conn: &Connection) -> (Option<i64>, Vec<i64>) {
|
||||||
let setting = conn
|
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();
|
.unwrap();
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id")
|
.prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id")
|
||||||
.unwrap();
|
.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)
|
(setting, flagged)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +284,10 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
assert_eq!(read(&conn).render_prefs, "");
|
assert_eq!(read(&conn).render_prefs, "");
|
||||||
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"])
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET render_prefs = ?1 WHERE id = 1",
|
||||||
|
params!["{\"version\":1}"],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let settings = read(&conn);
|
let settings = read(&conn);
|
||||||
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
|||||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||||
|
|
||||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||||
const LATEST_VERSION: i64 = 6;
|
pub(crate) const LATEST_VERSION: i64 = 12;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DbError {
|
pub enum DbError {
|
||||||
@@ -217,8 +217,170 @@ CREATE INDEX idx_user_fonts_sha ON user_fonts(sha256);
|
|||||||
CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0;
|
CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Version 7: structured client addresses, GST category and per-client defaults, plus saved item presets.
|
||||||
|
/// `clients.address` stays the composed multi-line string (issued invoices and the PDF read it); the new
|
||||||
|
/// parts are an editing aid. The address state is `clients.state_code`, which is also the default place
|
||||||
|
/// of supply, so there is no second state column. `payment_terms_days` NULL means "use the settings default".
|
||||||
|
/// `item_presets.rate_paise` is the fixed amount for mode 'fixed' and the per-unit rate for mode 'rate'.
|
||||||
|
const M7: &str = r#"
|
||||||
|
ALTER TABLE clients ADD COLUMN address_line1 TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE clients ADD COLUMN address_line2 TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE clients ADD COLUMN city TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE clients ADD COLUMN pincode TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE clients ADD COLUMN gst_category TEXT NOT NULL DEFAULT 'unregistered'
|
||||||
|
CHECK (gst_category IN ('registered_regular', 'unregistered', 'composition', 'sez', 'overseas'));
|
||||||
|
ALTER TABLE clients ADD COLUMN default_notes TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE clients ADD COLUMN payment_terms_days INTEGER;
|
||||||
|
UPDATE clients SET gst_category = 'registered_regular' WHERE gstin NOT IN ('', 'NA');
|
||||||
|
|
||||||
|
CREATE TABLE item_presets (
|
||||||
|
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 (unit IN ('second', 'minute', 'hour', 'session', 'unit')),
|
||||||
|
rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0),
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Version 8: payments received against issued invoices. Amounts are integer paise. `amount_paise` is the cash
|
||||||
|
/// received and `tds_paise` the tax the client deducted at source; both count towards settling the invoice.
|
||||||
|
/// Payment status is derived from these rows and the due date, never stored, and the issued invoice row is untouched.
|
||||||
|
const M8: &str = r#"
|
||||||
|
CREATE TABLE payments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
invoice_id INTEGER NOT NULL REFERENCES invoices(id),
|
||||||
|
paid_on TEXT NOT NULL,
|
||||||
|
amount_paise INTEGER NOT NULL DEFAULT 0 CHECK (amount_paise >= 0),
|
||||||
|
tds_paise INTEGER NOT NULL DEFAULT 0 CHECK (tds_paise >= 0),
|
||||||
|
mode TEXT NOT NULL CHECK (mode IN ('bank_transfer', 'upi', 'cash', 'cheque', 'other')),
|
||||||
|
reference TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
CHECK (amount_paise + tds_paise > 0)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Version 9: `auto_backup` switches the daily automatic backup (see commands::backup). On by default. It is read and
|
||||||
|
/// written through its own commands, not through `Settings`, so a settings save can never clobber it.
|
||||||
|
const M9: &str = r#"
|
||||||
|
ALTER TABLE app_settings ADD COLUMN auto_backup INTEGER NOT NULL DEFAULT 1;
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Version 10: ERPNext integration. `erpnext_config` is a single row (inserted here, so reading it is a plain SELECT)
|
||||||
|
/// holding the connection, the company/account/master mapping and the toggles. `api_secret` is stored here and is never
|
||||||
|
/// returned to the webview. `erpnext_sync` tracks what was pushed per invoice; issued invoices themselves are untouched.
|
||||||
|
/// The remote names on clients, payments and item presets are nullable: NULL means "not linked yet".
|
||||||
|
const M10: &str = r#"
|
||||||
|
CREATE TABLE erpnext_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
base_url TEXT NOT NULL DEFAULT '',
|
||||||
|
api_key TEXT NOT NULL DEFAULT '',
|
||||||
|
api_secret TEXT NOT NULL DEFAULT '',
|
||||||
|
extra_ca_pem TEXT NOT NULL DEFAULT '',
|
||||||
|
company TEXT NOT NULL DEFAULT '',
|
||||||
|
company_address TEXT NOT NULL DEFAULT '',
|
||||||
|
naming_mode TEXT NOT NULL DEFAULT 'mirror' CHECK (naming_mode IN ('mirror', 'series')),
|
||||||
|
naming_series TEXT NOT NULL DEFAULT '',
|
||||||
|
income_account TEXT NOT NULL DEFAULT '',
|
||||||
|
cost_center TEXT NOT NULL DEFAULT '',
|
||||||
|
cgst_account TEXT NOT NULL DEFAULT '',
|
||||||
|
sgst_account TEXT NOT NULL DEFAULT '',
|
||||||
|
utgst_account TEXT NOT NULL DEFAULT '',
|
||||||
|
igst_account TEXT NOT NULL DEFAULT '',
|
||||||
|
tax_template_intra TEXT NOT NULL DEFAULT '',
|
||||||
|
tax_template_inter TEXT NOT NULL DEFAULT '',
|
||||||
|
payment_bank_account TEXT NOT NULL DEFAULT '',
|
||||||
|
tds_account TEXT NOT NULL DEFAULT '',
|
||||||
|
default_item_code TEXT NOT NULL DEFAULT '',
|
||||||
|
uom_map TEXT NOT NULL DEFAULT '{"second":"Second","minute":"Minute","hour":"Hour","session":"Nos","unit":"Nos"}',
|
||||||
|
customer_group TEXT NOT NULL DEFAULT '',
|
||||||
|
territory TEXT NOT NULL DEFAULT '',
|
||||||
|
selling_price_list TEXT NOT NULL DEFAULT '',
|
||||||
|
submit_on_push INTEGER NOT NULL DEFAULT 0,
|
||||||
|
attach_pdf INTEGER NOT NULL DEFAULT 1,
|
||||||
|
auto_push_on_issue INTEGER NOT NULL DEFAULT 0,
|
||||||
|
create_missing_customers INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_detect_result TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO erpnext_config (id) VALUES (1);
|
||||||
|
|
||||||
|
CREATE TABLE erpnext_sync (
|
||||||
|
invoice_id INTEGER PRIMARY KEY REFERENCES invoices(id),
|
||||||
|
remote_name TEXT NOT NULL DEFAULT '',
|
||||||
|
remote_docstatus INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'error')),
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
payload_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
synced_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE clients ADD COLUMN erpnext_customer TEXT;
|
||||||
|
ALTER TABLE clients ADD COLUMN erpnext_address TEXT;
|
||||||
|
ALTER TABLE payments ADD COLUMN erpnext_payment_entry TEXT;
|
||||||
|
ALTER TABLE item_presets ADD COLUMN erpnext_item_code TEXT;
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Version 11: ERPNext push state. `erpnext_sync.status` gains `conflict` (the remote document exists with a different
|
||||||
|
/// total; we never overwrite it) and `attachment_sha256` records which archived PDF was uploaded so it is never attached
|
||||||
|
/// twice. SQLite cannot alter a CHECK constraint, so the table is rebuilt; M10 rows are copied over.
|
||||||
|
const M11: &str = r#"
|
||||||
|
CREATE TABLE erpnext_sync_new (
|
||||||
|
invoice_id INTEGER PRIMARY KEY REFERENCES invoices(id),
|
||||||
|
remote_name TEXT NOT NULL DEFAULT '',
|
||||||
|
remote_docstatus INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'error', 'conflict')),
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
payload_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
synced_at TEXT,
|
||||||
|
attachment_sha256 TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
INSERT INTO erpnext_sync_new (invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at)
|
||||||
|
SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync;
|
||||||
|
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> {
|
fn migrations() -> Migrations<'static> {
|
||||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)])
|
Migrations::new(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),
|
||||||
|
M::up(M12),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||||
@@ -238,7 +400,14 @@ pub fn open(path: &Path, backup_dir: &Path) -> Result<Connection, DbError> {
|
|||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
/// Write a consistent, compacted snapshot of the open database to `target` (which must not exist yet).
|
||||||
|
/// Safe while other connections read and write: SQLite takes a read transaction for the copy.
|
||||||
|
pub(crate) fn vacuum_into(conn: &Connection, target: &Path) -> rusqlite::Result<()> {
|
||||||
|
let quoted = target.to_string_lossy().replace('\'', "''");
|
||||||
|
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
||||||
[],
|
[],
|
||||||
@@ -256,8 +425,7 @@ fn backup_before_migration(
|
|||||||
fs::create_dir_all(backup_dir)?;
|
fs::create_dir_all(backup_dir)?;
|
||||||
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||||
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
||||||
let quoted = target.to_string_lossy().replace('\'', "''");
|
vacuum_into(conn, &target)?;
|
||||||
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
|
|
||||||
// A failed prune must not block startup; the backup itself already succeeded.
|
// A failed prune must not block startup; the backup itself already succeeded.
|
||||||
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
||||||
Ok(target)
|
Ok(target)
|
||||||
@@ -290,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.
|
/// 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
|
/// The defaults are neutral: no vendor, bank or tax identity is pre-filled, so the setup wizard
|
||||||
/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax).
|
/// starts blank. The vendor is an unregistered supplier (no tax) until the wizard says otherwise.
|
||||||
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
let settings_count: i64 =
|
let settings_count: i64 =
|
||||||
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
||||||
@@ -301,38 +469,8 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
||||||
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
||||||
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
||||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
VALUES (1, '', '', '', '', '', '', '', 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],
|
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +489,28 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
Ok(())
|
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.
|
/// A migrated, seeded in-memory database for unit tests of the command logic.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn open_in_memory() -> Result<Connection, DbError> {
|
pub fn open_in_memory() -> Result<Connection, DbError> {
|
||||||
@@ -358,6 +518,7 @@ pub fn open_in_memory() -> Result<Connection, DbError> {
|
|||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
migrations().to_latest(&mut conn)?;
|
migrations().to_latest(&mut conn)?;
|
||||||
seed(&conn)?;
|
seed(&conn)?;
|
||||||
|
seed_test_fixture(&conn)?;
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,8 +567,9 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R
|
|||||||
}
|
}
|
||||||
|
|
||||||
let snapshots: Vec<(i64, String)> = {
|
let snapshots: Vec<(i64, String)> = {
|
||||||
let mut stmt =
|
let mut stmt = tx.prepare(
|
||||||
tx.prepare("SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL")?;
|
"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)?)))?;
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
rows.collect::<rusqlite::Result<_>>()?
|
rows.collect::<rusqlite::Result<_>>()?
|
||||||
};
|
};
|
||||||
@@ -438,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".
|
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
|
||||||
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
||||||
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
|
format!(
|
||||||
|
"{}{:0width$}",
|
||||||
|
prefix,
|
||||||
|
number,
|
||||||
|
width = padding.max(1) as usize
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -546,7 +713,8 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
"#;
|
"#;
|
||||||
|
|
||||||
fn user_version(conn: &Connection) -> i64 {
|
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> {
|
fn backups_in(dir: &Path) -> Vec<PathBuf> {
|
||||||
@@ -580,7 +748,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(tax_type, "none");
|
assert_eq!(tax_type, "none");
|
||||||
assert_eq!(gst_reg, "unregistered");
|
assert_eq!(gst_reg, "unregistered");
|
||||||
assert_eq!(name, "Test Vendor");
|
assert_eq!(name, "");
|
||||||
assert!(backups_in(&backups).is_empty());
|
assert!(backups_in(&backups).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +756,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
fn m4_adds_archive_and_export_dir_columns() {
|
fn m4_adds_archive_and_export_dir_columns() {
|
||||||
let conn = open_in_memory().unwrap();
|
let conn = open_in_memory().unwrap();
|
||||||
let dir: String = conn
|
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();
|
.unwrap();
|
||||||
assert_eq!(dir, "");
|
assert_eq!(dir, "");
|
||||||
// The columns exist and are nullable on invoices (no rows needed to prove it).
|
// The columns exist and are nullable on invoices (no rows needed to prove it).
|
||||||
@@ -609,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() {
|
fn render_prefs_column_defaults_to_empty_and_round_trips() {
|
||||||
let conn = open_in_memory().unwrap();
|
let conn = open_in_memory().unwrap();
|
||||||
let initial: String = conn
|
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();
|
.unwrap();
|
||||||
assert_eq!(initial, "");
|
assert_eq!(initial, "");
|
||||||
|
|
||||||
let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#;
|
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
|
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();
|
.unwrap();
|
||||||
assert_eq!(stored, json);
|
assert_eq!(stored, json);
|
||||||
}
|
}
|
||||||
@@ -643,20 +827,363 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
.to_latest(&mut old)
|
.to_latest(&mut old)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
seed(&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);
|
assert_eq!(user_version(&old), 4);
|
||||||
}
|
}
|
||||||
let conn = open(&path, &dir.path().join("backups")).unwrap();
|
let conn = open(&path, &dir.path().join("backups")).unwrap();
|
||||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
let (logo, meta): (String, String) = conn
|
let (logo, meta): (String, String) = conn
|
||||||
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
"SELECT logo_path, logo_meta FROM app_settings WHERE id = 1",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", ""));
|
assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", ""));
|
||||||
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
|
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m7_fresh_database_has_client_and_preset_columns() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
conn.prepare(
|
||||||
|
"SELECT address_line1, address_line2, city, pincode, gst_category, default_notes, payment_terms_days FROM clients",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at FROM item_presets")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO clients (name, created_at) VALUES ('X', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.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)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((cat.as_str(), terms), ("unregistered", None));
|
||||||
|
// The category CHECK rejects unknown values.
|
||||||
|
assert!(conn
|
||||||
|
.execute("UPDATE clients SET gst_category = 'bogus'", [])
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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),
|
||||||
|
];
|
||||||
|
Migrations::new(all).to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), 6);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||||
|
VALUES ('Acme', 'Line one\nLine two', '29ABCDE1234F1Z5', '29', 'PO-1', 'now'),
|
||||||
|
('Plain', '', '', '', '', 'now'),
|
||||||
|
('Na', '', 'NA', '', '', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
|
|
||||||
|
type Row = (String, String, String, String, String, String, String);
|
||||||
|
let rows: Vec<Row> = conn
|
||||||
|
.prepare("SELECT name, address, gstin, state_code, po_number, gst_category, address_line1 FROM clients 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)?))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.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].5, "registered_regular");
|
||||||
|
assert_eq!(rows[0].6, "");
|
||||||
|
assert_eq!(rows[1].5, "unregistered");
|
||||||
|
assert_eq!(rows[2].5, "unregistered");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m10_fresh_database_has_the_erpnext_tables_and_a_default_config_row() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let (naming, submit, attach, auto_push, create_customers, secret, uom): (String, i64, i64, i64, i64, String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT naming_mode, submit_on_push, attach_pdf, auto_push_on_issue, create_missing_customers, api_secret, uom_map
|
||||||
|
FROM erpnext_config WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|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!(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();
|
||||||
|
assert_eq!(rows, 1);
|
||||||
|
// Single row only.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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),
|
||||||
|
];
|
||||||
|
Migrations::new(v9).to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), 9);
|
||||||
|
conn.execute("INSERT INTO clients (name, gstin, created_at) VALUES ('Acme', '29ABCDE1234F1Z5', 'now')", []).unwrap();
|
||||||
|
conn.execute("INSERT INTO item_presets (description, rate_paise, created_at) VALUES ('Design', 500000, 'now')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO payments (invoice_id, paid_on, amount_paise, mode, created_at) VALUES (1, '2026-04-10', 12345, 'upi', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
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)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
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)?)),
|
||||||
|
)
|
||||||
|
.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)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((paid, entry), (12345, None));
|
||||||
|
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();
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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),
|
||||||
|
];
|
||||||
|
Migrations::new(v10).to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), 10);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO erpnext_sync (invoice_id, remote_name, remote_docstatus, status, payload_hash, synced_at)
|
||||||
|
VALUES (1, 'INV/2026-001', 1, 'synced', 'abc', '2026-04-02T00:00:00Z')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(conn
|
||||||
|
.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
|
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",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
row,
|
||||||
|
(
|
||||||
|
"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());
|
||||||
|
// The unique key still guards one row per invoice.
|
||||||
|
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) {
|
fn build_v1_database(path: &Path) {
|
||||||
let conn = Connection::open(path).unwrap();
|
let conn = Connection::open(path).unwrap();
|
||||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||||
@@ -707,7 +1234,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
let files = backups_in(&backups);
|
let files = backups_in(&backups);
|
||||||
assert_eq!(files.len(), 1);
|
assert_eq!(files.len(), 1);
|
||||||
let name = files[0].file_name().unwrap().to_str().unwrap();
|
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();
|
let backup = Connection::open(&files[0]).unwrap();
|
||||||
assert_eq!(user_version(&backup), 0);
|
assert_eq!(user_version(&backup), 0);
|
||||||
let n: i64 = backup
|
let n: i64 = backup
|
||||||
@@ -728,7 +1258,13 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
assert_eq!(vendor, "Acme Studio");
|
assert_eq!(vendor, "Acme Studio");
|
||||||
|
|
||||||
// Invoice backfill.
|
// 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(
|
.query_row(
|
||||||
"SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices",
|
"SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices",
|
||||||
[],
|
[],
|
||||||
@@ -796,7 +1332,9 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
.collect();
|
.collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
assert_eq!(names.len(), MAX_BACKUPS);
|
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());
|
assert!(dir.path().join("unrelated.db").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,8 +1343,16 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let data = dir.path();
|
let data = dir.path();
|
||||||
let conn = open(&data.join("voiced.db"), &data.join("backups")).unwrap();
|
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_logo = data
|
||||||
let abs_sig = data.join("assets").join("signature-2.jpg").to_string_lossy().into_owned();
|
.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";
|
let foreign = "/somewhere/else/logo.png";
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE app_settings SET logo_path = ?1, signature_path = ?2",
|
"UPDATE app_settings SET logo_path = ?1, signature_path = ?2",
|
||||||
@@ -835,9 +1381,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
relativize_asset_paths(&conn, data).unwrap();
|
relativize_asset_paths(&conn, data).unwrap();
|
||||||
let (logo, sig): (String, String) = conn
|
let (logo, sig): (String, String) = conn
|
||||||
.query_row("SELECT logo_path, signature_path FROM app_settings", [], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
"SELECT logo_path, signature_path FROM app_settings",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(logo, "assets/logo-1.png");
|
assert_eq!(logo, "assets/logo-1.png");
|
||||||
assert_eq!(sig, foreign);
|
assert_eq!(sig, foreign);
|
||||||
@@ -854,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["signaturePath"], "assets/signature-2.jpg");
|
||||||
assert_eq!(json["vendorName"], "Acme");
|
assert_eq!(json["vendorName"], "Acme");
|
||||||
let bad: String = conn
|
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();
|
.unwrap();
|
||||||
assert_eq!(bad, "not json");
|
assert_eq!(bad, "not json");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub const STATES: &[(&str, &str)] = &[
|
|||||||
("09", "Uttar Pradesh"),
|
("09", "Uttar Pradesh"),
|
||||||
("10", "Bihar"),
|
("10", "Bihar"),
|
||||||
("11", "Sikkim"),
|
("11", "Sikkim"),
|
||||||
("12", "Sampleachal Pradesh"),
|
("12", "Arunachal Pradesh"),
|
||||||
("13", "Nagaland"),
|
("13", "Nagaland"),
|
||||||
("14", "Manipur"),
|
("14", "Manipur"),
|
||||||
("15", "Mizoram"),
|
("15", "Mizoram"),
|
||||||
@@ -75,7 +75,11 @@ pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str
|
|||||||
if registration == "unregistered" {
|
if registration == "unregistered" {
|
||||||
return TaxType::None;
|
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 {
|
if pos == supplier_state {
|
||||||
TaxType::CgstSgst
|
TaxType::CgstSgst
|
||||||
} else {
|
} else {
|
||||||
@@ -225,8 +229,26 @@ pub fn compute_totals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ONES: [&str; 20] = [
|
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] = [
|
const TENS: [&str; 10] = [
|
||||||
@@ -353,9 +375,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tax_type_serde_strings() {
|
fn tax_type_serde_strings() {
|
||||||
assert_eq!(serde_json::to_string(&TaxType::CgstSgst).unwrap(), "\"cgst_sgst\"");
|
assert_eq!(
|
||||||
assert_eq!(serde_json::from_str::<TaxType>("\"igst\"").unwrap(), TaxType::Igst);
|
serde_json::to_string(&TaxType::CgstSgst).unwrap(),
|
||||||
assert_eq!(serde_json::from_str::<TaxType>("\"none\"").unwrap(), TaxType::None);
|
"\"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]
|
#[test]
|
||||||
@@ -384,17 +415,29 @@ mod tests {
|
|||||||
assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(()));
|
assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(()));
|
||||||
// The algorithm gives W for this body, so a trailing 5 is a typo.
|
// The algorithm gives W for this body, so a trailing 5 is a typo.
|
||||||
assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(()));
|
assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(()));
|
||||||
assert!(validate_gstin("29ABCDE1234F1Z5").unwrap_err().contains("check digit"));
|
assert!(validate_gstin("29ABCDE1234F1Z5")
|
||||||
assert!(validate_gstin("27AAPFU0939F1ZX").unwrap_err().contains("check digit"));
|
.unwrap_err()
|
||||||
|
.contains("check digit"));
|
||||||
|
assert!(validate_gstin("27AAPFU0939F1ZX")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("check digit"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gstin_rejects_bad_shapes() {
|
fn gstin_rejects_bad_shapes() {
|
||||||
assert!(validate_gstin("").unwrap_err().contains("15 characters"));
|
assert!(validate_gstin("").unwrap_err().contains("15 characters"));
|
||||||
assert!(validate_gstin("27AAPFU0939F1Z").unwrap_err().contains("15 characters"));
|
assert!(validate_gstin("27AAPFU0939F1Z")
|
||||||
assert!(validate_gstin("27AAPFU0939F1ZVV").unwrap_err().contains("15 characters"));
|
.unwrap_err()
|
||||||
assert!(validate_gstin("2XAAPFU0939F1ZV").unwrap_err().contains("format"));
|
.contains("15 characters"));
|
||||||
assert!(validate_gstin("27AAPFU0939F1AV").unwrap_err().contains("format"));
|
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.
|
// Valid shape and checksum but state 99 does not exist.
|
||||||
let body = b"99AAPFU0939F1Z";
|
let body = b"99AAPFU0939F1Z";
|
||||||
let mut g = String::from_utf8(body.to_vec()).unwrap();
|
let mut g = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,689 @@
|
|||||||
|
//! HTTP client for a Frappe/ERPNext site. All requests run here, in Rust: a webview `fetch` from
|
||||||
|
//! `tauri://localhost` would be blocked by CORS.
|
||||||
|
//!
|
||||||
|
//! TLS is rustls with the `ring` provider (installed once, process-wide). Roots come from the OS through
|
||||||
|
//! rustls-platform-verifier; an optional extra CA PEM is merged in for self-hosted sites.
|
||||||
|
|
||||||
|
use super::config::{ErpnextConfig, Secret};
|
||||||
|
use super::errors::{ErpError, ErrorKind};
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, RETRY_AFTER};
|
||||||
|
use reqwest::{Method, Url};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::sync::Once;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
static CRYPTO_PROVIDER: Once = Once::new();
|
||||||
|
|
||||||
|
/// Installs the ring crypto provider for rustls. Idempotent; must run before the first client is built.
|
||||||
|
pub fn ensure_crypto_provider() {
|
||||||
|
CRYPTO_PROVIDER.call_once(|| {
|
||||||
|
// Err means another provider is already installed, which is fine for our purposes.
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ClientOptions {
|
||||||
|
pub connect_timeout: Duration,
|
||||||
|
pub read_timeout: Duration,
|
||||||
|
pub total_timeout: Duration,
|
||||||
|
/// Retries after the first attempt.
|
||||||
|
pub max_retries: u32,
|
||||||
|
pub backoff_base: Duration,
|
||||||
|
pub max_backoff: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClientOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
ClientOptions {
|
||||||
|
connect_timeout: Duration::from_secs(10),
|
||||||
|
read_timeout: Duration::from_secs(30),
|
||||||
|
total_timeout: Duration::from_secs(60),
|
||||||
|
max_retries: 3,
|
||||||
|
backoff_base: Duration::from_millis(500),
|
||||||
|
max_backoff: Duration::from_secs(8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash.
|
||||||
|
/// A missing scheme means https, except for local development hosts, which get http.
|
||||||
|
pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(ErpError::config("The ERPNext address is empty."));
|
||||||
|
}
|
||||||
|
let with_scheme = if raw.contains("://") {
|
||||||
|
raw.to_string()
|
||||||
|
} else {
|
||||||
|
let host = raw.split(['/', ':']).next().unwrap_or("");
|
||||||
|
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 host = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| ErpError::config("The ERPNext address has no host name."))?
|
||||||
|
.to_string();
|
||||||
|
match url.scheme() {
|
||||||
|
"https" => {}
|
||||||
|
"http" => {
|
||||||
|
if !is_local_dev_host(&host) {
|
||||||
|
return Err(ErpError::config(
|
||||||
|
"Use an https:// address. Plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
url.set_query(None);
|
||||||
|
url.set_fragment(None);
|
||||||
|
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file to upload through a multipart POST.
|
||||||
|
pub struct Upload<'a> {
|
||||||
|
pub file_name: &'a str,
|
||||||
|
pub mime: &'a str,
|
||||||
|
pub bytes: &'a [u8],
|
||||||
|
pub fields: &'a [(&'a str, String)],
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Payload<'a> {
|
||||||
|
None,
|
||||||
|
Json(&'a Value),
|
||||||
|
Upload(&'a Upload<'a>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ErpClient {
|
||||||
|
http: reqwest::Client,
|
||||||
|
base: Url,
|
||||||
|
opts: ClientOptions,
|
||||||
|
/// Strings scrubbed from every error message.
|
||||||
|
secrets: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErpClient {
|
||||||
|
pub fn new(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
api_secret: &Secret,
|
||||||
|
extra_ca_pem: &str,
|
||||||
|
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()))?;
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
auth.set_sensitive(true);
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(AUTHORIZATION, auth);
|
||||||
|
// Frappe answers `Accept: text/*` with HTML; always ask for JSON.
|
||||||
|
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||||
|
|
||||||
|
let mut builder = reqwest::Client::builder()
|
||||||
|
.default_headers(headers)
|
||||||
|
.user_agent(concat!("Voiced/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.connect_timeout(opts.connect_timeout)
|
||||||
|
.read_timeout(opts.read_timeout)
|
||||||
|
.timeout(opts.total_timeout)
|
||||||
|
// A redirect drops the body or the auth header; surface it instead of guessing.
|
||||||
|
.redirect(reqwest::redirect::Policy::none());
|
||||||
|
if !extra_ca_pem.trim().is_empty() {
|
||||||
|
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.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(ErpClient {
|
||||||
|
http,
|
||||||
|
base,
|
||||||
|
opts,
|
||||||
|
secrets: vec![api_secret.expose().to_string(), api_key.trim().to_string()],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url(&self) -> &str {
|
||||||
|
self.base.as_str().trim_end_matches('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, segments: &[&str]) -> Url {
|
||||||
|
let mut url = self.base.clone();
|
||||||
|
if let Ok(mut path) = url.path_segments_mut() {
|
||||||
|
path.pop_if_empty().extend(segments);
|
||||||
|
}
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
segments: &[&str],
|
||||||
|
query: &[(&str, String)],
|
||||||
|
payload: Payload<'_>,
|
||||||
|
retry_ambiguous: bool,
|
||||||
|
) -> Result<Value, ErpError> {
|
||||||
|
let mut attempt: u32 = 0;
|
||||||
|
loop {
|
||||||
|
let mut req = self.http.request(method.clone(), self.url(segments));
|
||||||
|
if !query.is_empty() {
|
||||||
|
req = req.query(query);
|
||||||
|
}
|
||||||
|
match &payload {
|
||||||
|
Payload::None => {}
|
||||||
|
Payload::Json(b) => req = req.json(*b),
|
||||||
|
Payload::Upload(u) => {
|
||||||
|
let part = reqwest::multipart::Part::bytes(u.bytes.to_vec())
|
||||||
|
.file_name(u.file_name.to_string())
|
||||||
|
.mime_str(u.mime)
|
||||||
|
.map_err(|e| ErpError::config(format!("Bad upload type: {e}")))?;
|
||||||
|
let mut form = reqwest::multipart::Form::new();
|
||||||
|
for (k, v) in u.fields {
|
||||||
|
form = form.text(k.to_string(), v.clone());
|
||||||
|
}
|
||||||
|
req = req.multipart(form.part("file", part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let can_retry = attempt < self.opts.max_retries;
|
||||||
|
match req.send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
let retry_after = resp
|
||||||
|
.headers()
|
||||||
|
.get(RETRY_AFTER)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||||
|
.map(Duration::from_secs);
|
||||||
|
let location = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::LOCATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(str::to_string);
|
||||||
|
let text = match resp.text().await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
if e.is_timeout() && retry_ambiguous && can_retry {
|
||||||
|
self.backoff(attempt, None).await;
|
||||||
|
attempt += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(self.map_transport(&e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if status.is_success() {
|
||||||
|
return self.parse_success(&text);
|
||||||
|
}
|
||||||
|
let retryable =
|
||||||
|
status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
|
||||||
|
if retryable && can_retry {
|
||||||
|
self.backoff(attempt, retry_after).await;
|
||||||
|
attempt += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if status.is_redirection() {
|
||||||
|
let to = location.unwrap_or_else(|| "another address".to_string());
|
||||||
|
return Err(self.scrub(ErpError::protocol(format!(
|
||||||
|
"The server redirected the request to {to}. Use the final address (usually https://) in the ERPNext address field."
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
return Err(self.scrub(ErpError::from_response(status.as_u16(), &text)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if e.is_timeout() && retry_ambiguous && can_retry {
|
||||||
|
self.backoff(attempt, None).await;
|
||||||
|
attempt += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(self.map_transport(&e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_success(&self, text: &str) -> Result<Value, ErpError> {
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return Ok(Value::Null);
|
||||||
|
}
|
||||||
|
serde_json::from_str(text).map_err(|_| {
|
||||||
|
ErpError::protocol(
|
||||||
|
"The server did not return JSON. Check the address: it should be the root of your ERPNext site.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scrub(&self, err: ErpError) -> ErpError {
|
||||||
|
let secrets: Vec<&str> = self.secrets.iter().map(String::as_str).collect();
|
||||||
|
err.redacted(&secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
} else if e.is_connect() {
|
||||||
|
let lower = chain.to_ascii_lowercase();
|
||||||
|
let hint = if lower.contains("certificate") || lower.contains("unknownissuer") {
|
||||||
|
" The certificate is not trusted: paste the site's CA certificate into the extra CA field."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
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})."),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.scrub(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `a: b: c` for an error and its sources.
|
||||||
|
fn error_chain(e: &dyn std::error::Error) -> String {
|
||||||
|
let mut parts = vec![e.to_string()];
|
||||||
|
let mut source = e.source();
|
||||||
|
while let Some(s) = source {
|
||||||
|
let text = s.to_string();
|
||||||
|
if parts.last() != Some(&text) {
|
||||||
|
parts.push(text);
|
||||||
|
}
|
||||||
|
source = s.source();
|
||||||
|
}
|
||||||
|
parts.join(": ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::integrations::erpnext::testutil::{client, fast_opts, reply, serve, Reply};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
// ---- URL rules ----
|
||||||
|
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_is_only_for_local_development_hosts() {
|
||||||
|
for ok in [
|
||||||
|
"http://localhost:8000",
|
||||||
|
"http://127.0.0.1:8080",
|
||||||
|
"http://mysite.localhost:8000",
|
||||||
|
"http://erp.test",
|
||||||
|
"localhost:8000",
|
||||||
|
"127.0.0.1:8000",
|
||||||
|
] {
|
||||||
|
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",
|
||||||
|
] {
|
||||||
|
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://",
|
||||||
|
] {
|
||||||
|
assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sends_token_auth_and_a_json_accept_header() {
|
||||||
|
let mock = serve(vec![reply(200, json!({ "message": "Sample@example.com" }))]).await;
|
||||||
|
let value = client(&mock)
|
||||||
|
.get(&["api", "method", "frappe.auth.get_logged_user"], &[])
|
||||||
|
.await
|
||||||
|
.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.contains("accept: application/json"), "{raw}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
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())],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let raw = mock.requests.lock().unwrap()[0].clone();
|
||||||
|
assert!(
|
||||||
|
raw.starts_with(
|
||||||
|
"GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "
|
||||||
|
),
|
||||||
|
"{raw}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn retries_429_then_succeeds() {
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
assert_eq!(err.kind, ErrorKind::Server);
|
||||||
|
assert_eq!(err.message, "boom");
|
||||||
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn validation_errors_are_not_retried() {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.kind, ErrorKind::Validation);
|
||||||
|
assert_eq!(err.message, "Rate is required");
|
||||||
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 mock = serve(vec![slow]).await;
|
||||||
|
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 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 value = client(&mock)
|
||||||
|
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(value["data"]["name"], "X");
|
||||||
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
|
||||||
|
|
||||||
|
let mock = serve(vec![reply(500, json!({}))]).await;
|
||||||
|
let err = client(&mock)
|
||||||
|
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.kind, ErrorKind::Server);
|
||||||
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redirects_are_reported_not_followed() {
|
||||||
|
let mut moved = reply(301, json!({}));
|
||||||
|
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();
|
||||||
|
assert_eq!(err.kind, ErrorKind::Protocol);
|
||||||
|
assert!(
|
||||||
|
err.message.contains("https://erp.example.com/"),
|
||||||
|
"{}",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn html_success_page_is_a_protocol_error() {
|
||||||
|
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();
|
||||||
|
assert_eq!(err.kind, ErrorKind::Protocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refused_connection_is_a_network_error_without_the_secret() {
|
||||||
|
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 err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
||||||
|
assert_eq!(err.kind, ErrorKind::Network);
|
||||||
|
assert!(!err.to_string().contains("thesecret"));
|
||||||
|
assert!(!format!("{c:?}").contains("thesecret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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_eq!(err.kind, ErrorKind::Config);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
//! ERPNext connection and mapping settings (the single `erpnext_config` row).
|
||||||
|
//!
|
||||||
|
//! The API secret lives in the SQLite DB and is never handed back to the webview: `ErpnextConfigView`
|
||||||
|
//! carries only `api_secret_set`. `Secret` redacts itself in `Debug`, so a stray `{:?}` cannot leak it.
|
||||||
|
|
||||||
|
use super::client::normalize_base_url;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// A credential that never prints itself.
|
||||||
|
#[derive(Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Secret(String);
|
||||||
|
|
||||||
|
impl Secret {
|
||||||
|
pub fn new(value: impl Into<String>) -> Self {
|
||||||
|
Secret(value.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expose(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_set(&self) -> bool {
|
||||||
|
!self.0.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the Sales Invoice gets its name in ERPNext.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum NamingMode {
|
||||||
|
/// The Voiced number becomes the document name (API v2, Frappe >= 15.73).
|
||||||
|
Mirror,
|
||||||
|
/// ERPNext assigns its own series number; the Voiced number goes into `remarks`.
|
||||||
|
Series,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NamingMode {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
NamingMode::Mirror => "mirror",
|
||||||
|
NamingMode::Series => "series",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> Result<Self, String> {
|
||||||
|
match s {
|
||||||
|
"mirror" => Ok(NamingMode::Mirror),
|
||||||
|
"series" => Ok(NamingMode::Series),
|
||||||
|
other => Err(format!("Unknown naming mode \"{other}\"")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities,
|
||||||
|
/// 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> {
|
||||||
|
crate::units::KNOWN
|
||||||
|
.iter()
|
||||||
|
.map(|(id, uom)| (id.to_string(), uom.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything stored for the integration, secret included. Backend-only.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ErpnextConfig {
|
||||||
|
pub base_url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub api_secret: Secret,
|
||||||
|
pub extra_ca_pem: String,
|
||||||
|
pub company: String,
|
||||||
|
pub company_address: String,
|
||||||
|
pub naming_mode: NamingMode,
|
||||||
|
pub naming_series: String,
|
||||||
|
pub income_account: String,
|
||||||
|
pub cost_center: String,
|
||||||
|
pub cgst_account: String,
|
||||||
|
pub sgst_account: String,
|
||||||
|
pub utgst_account: String,
|
||||||
|
pub igst_account: String,
|
||||||
|
pub tax_template_intra: String,
|
||||||
|
pub tax_template_inter: String,
|
||||||
|
pub payment_bank_account: String,
|
||||||
|
pub tds_account: String,
|
||||||
|
pub default_item_code: String,
|
||||||
|
pub uom_map: BTreeMap<String, String>,
|
||||||
|
pub customer_group: String,
|
||||||
|
pub territory: String,
|
||||||
|
pub selling_price_list: String,
|
||||||
|
pub submit_on_push: bool,
|
||||||
|
pub attach_pdf: bool,
|
||||||
|
pub auto_push_on_issue: bool,
|
||||||
|
pub create_missing_customers: bool,
|
||||||
|
/// JSON of the last connection test (versions, IC flag, warnings); empty if never run. Never holds secrets.
|
||||||
|
pub last_detect_result: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ErpnextConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
ErpnextConfig {
|
||||||
|
base_url: String::new(),
|
||||||
|
api_key: String::new(),
|
||||||
|
api_secret: Secret::default(),
|
||||||
|
extra_ca_pem: String::new(),
|
||||||
|
company: String::new(),
|
||||||
|
company_address: String::new(),
|
||||||
|
naming_mode: NamingMode::Mirror,
|
||||||
|
naming_series: String::new(),
|
||||||
|
income_account: String::new(),
|
||||||
|
cost_center: String::new(),
|
||||||
|
cgst_account: String::new(),
|
||||||
|
sgst_account: String::new(),
|
||||||
|
utgst_account: String::new(),
|
||||||
|
igst_account: String::new(),
|
||||||
|
tax_template_intra: String::new(),
|
||||||
|
tax_template_inter: String::new(),
|
||||||
|
payment_bank_account: String::new(),
|
||||||
|
tds_account: String::new(),
|
||||||
|
default_item_code: String::new(),
|
||||||
|
uom_map: default_uom_map(),
|
||||||
|
customer_group: String::new(),
|
||||||
|
territory: String::new(),
|
||||||
|
selling_price_list: String::new(),
|
||||||
|
submit_on_push: false,
|
||||||
|
attach_pdf: true,
|
||||||
|
auto_push_on_issue: false,
|
||||||
|
create_missing_customers: true,
|
||||||
|
last_detect_result: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErpnextConfig {
|
||||||
|
/// ERPNext UOM for a Voiced unit; falls back to the built-in default, then to "Nos".
|
||||||
|
pub fn uom_for(&self, unit: &str) -> String {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(&self) -> ErpnextConfigView {
|
||||||
|
ErpnextConfigView {
|
||||||
|
base_url: self.base_url.clone(),
|
||||||
|
api_key: self.api_key.clone(),
|
||||||
|
api_secret_set: self.api_secret.is_set(),
|
||||||
|
extra_ca_pem: self.extra_ca_pem.clone(),
|
||||||
|
company: self.company.clone(),
|
||||||
|
company_address: self.company_address.clone(),
|
||||||
|
naming_mode: self.naming_mode,
|
||||||
|
naming_series: self.naming_series.clone(),
|
||||||
|
income_account: self.income_account.clone(),
|
||||||
|
cost_center: self.cost_center.clone(),
|
||||||
|
cgst_account: self.cgst_account.clone(),
|
||||||
|
sgst_account: self.sgst_account.clone(),
|
||||||
|
utgst_account: self.utgst_account.clone(),
|
||||||
|
igst_account: self.igst_account.clone(),
|
||||||
|
tax_template_intra: self.tax_template_intra.clone(),
|
||||||
|
tax_template_inter: self.tax_template_inter.clone(),
|
||||||
|
payment_bank_account: self.payment_bank_account.clone(),
|
||||||
|
tds_account: self.tds_account.clone(),
|
||||||
|
default_item_code: self.default_item_code.clone(),
|
||||||
|
uom_map: self.uom_map.clone(),
|
||||||
|
customer_group: self.customer_group.clone(),
|
||||||
|
territory: self.territory.clone(),
|
||||||
|
selling_price_list: self.selling_price_list.clone(),
|
||||||
|
submit_on_push: self.submit_on_push,
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the webview sees: the same settings minus the secret.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ErpnextConfigView {
|
||||||
|
pub base_url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub api_secret_set: bool,
|
||||||
|
pub extra_ca_pem: String,
|
||||||
|
pub company: String,
|
||||||
|
pub company_address: String,
|
||||||
|
pub naming_mode: NamingMode,
|
||||||
|
pub naming_series: String,
|
||||||
|
pub income_account: String,
|
||||||
|
pub cost_center: String,
|
||||||
|
pub cgst_account: String,
|
||||||
|
pub sgst_account: String,
|
||||||
|
pub utgst_account: String,
|
||||||
|
pub igst_account: String,
|
||||||
|
pub tax_template_intra: String,
|
||||||
|
pub tax_template_inter: String,
|
||||||
|
pub payment_bank_account: String,
|
||||||
|
pub tds_account: String,
|
||||||
|
pub default_item_code: String,
|
||||||
|
pub uom_map: BTreeMap<String, String>,
|
||||||
|
pub customer_group: String,
|
||||||
|
pub territory: String,
|
||||||
|
pub selling_price_list: String,
|
||||||
|
pub submit_on_push: bool,
|
||||||
|
pub attach_pdf: bool,
|
||||||
|
pub auto_push_on_issue: bool,
|
||||||
|
pub create_missing_customers: bool,
|
||||||
|
pub last_detect_result: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the webview sends to save or test. `api_secret` blank keeps the stored secret; a non-blank
|
||||||
|
/// value replaces it; `clear_secret` removes it.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ErpnextConfigInput {
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_secret: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub clear_secret: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_ca_pem: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub company: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub company_address: String,
|
||||||
|
#[serde(default = "default_naming_mode")]
|
||||||
|
pub naming_mode: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub naming_series: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub income_account: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cost_center: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cgst_account: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sgst_account: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub utgst_account: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub igst_account: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tax_template_intra: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tax_template_inter: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payment_bank_account: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tds_account: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_item_code: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub uom_map: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub customer_group: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub territory: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub selling_price_list: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub submit_on_push: bool,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub attach_pdf: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_push_on_issue: bool,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub create_missing_customers: bool,
|
||||||
|
/// None keeps the stored value.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_detect_result: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_naming_mode() -> String {
|
||||||
|
"mirror".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn origin_of(url: &str) -> String {
|
||||||
|
url.split('/')
|
||||||
|
.take(3)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErpnextConfigInput {
|
||||||
|
/// Merges the form values with what is stored: trims, normalises the address, and decides which
|
||||||
|
/// secret applies. The stored secret is never sent to a different server than it was saved for.
|
||||||
|
pub fn resolve(self, stored: &ErpnextConfig) -> Result<ErpnextConfig, String> {
|
||||||
|
let t = |s: &str| s.trim().to_string();
|
||||||
|
let raw_url = self.base_url.trim();
|
||||||
|
let base_url = if raw_url.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
normalize_base_url(raw_url).map_err(|e| e.to_string())?
|
||||||
|
};
|
||||||
|
let typed_secret = self.api_secret.trim();
|
||||||
|
let api_secret = if self.clear_secret {
|
||||||
|
Secret::default()
|
||||||
|
} else if !typed_secret.is_empty() {
|
||||||
|
Secret::new(typed_secret)
|
||||||
|
} else {
|
||||||
|
if stored.api_secret.is_set()
|
||||||
|
&& !stored.base_url.is_empty()
|
||||||
|
&& !base_url.is_empty()
|
||||||
|
&& origin_of(&stored.base_url) != origin_of(&base_url)
|
||||||
|
{
|
||||||
|
return Err("The server address changed: enter the API secret again.".into());
|
||||||
|
}
|
||||||
|
stored.api_secret.clone()
|
||||||
|
};
|
||||||
|
let mut uom_map = default_uom_map();
|
||||||
|
for (unit, uom) in &self.uom_map {
|
||||||
|
if !uom.trim().is_empty() {
|
||||||
|
uom_map.insert(unit.trim().to_string(), uom.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let last_detect_result = match &self.last_detect_result {
|
||||||
|
Some(Value::Null) => String::new(),
|
||||||
|
Some(v) => v.to_string(),
|
||||||
|
None => stored.last_detect_result.clone(),
|
||||||
|
};
|
||||||
|
Ok(ErpnextConfig {
|
||||||
|
base_url,
|
||||||
|
api_key: t(&self.api_key),
|
||||||
|
api_secret,
|
||||||
|
extra_ca_pem: self.extra_ca_pem.trim().to_string(),
|
||||||
|
company: t(&self.company),
|
||||||
|
company_address: t(&self.company_address),
|
||||||
|
naming_mode: NamingMode::parse(self.naming_mode.trim())?,
|
||||||
|
naming_series: t(&self.naming_series),
|
||||||
|
income_account: t(&self.income_account),
|
||||||
|
cost_center: t(&self.cost_center),
|
||||||
|
cgst_account: t(&self.cgst_account),
|
||||||
|
sgst_account: t(&self.sgst_account),
|
||||||
|
utgst_account: t(&self.utgst_account),
|
||||||
|
igst_account: t(&self.igst_account),
|
||||||
|
tax_template_intra: t(&self.tax_template_intra),
|
||||||
|
tax_template_inter: t(&self.tax_template_inter),
|
||||||
|
payment_bank_account: t(&self.payment_bank_account),
|
||||||
|
tds_account: t(&self.tds_account),
|
||||||
|
default_item_code: t(&self.default_item_code),
|
||||||
|
uom_map,
|
||||||
|
customer_group: t(&self.customer_group),
|
||||||
|
territory: t(&self.territory),
|
||||||
|
selling_price_list: t(&self.selling_price_list),
|
||||||
|
submit_on_push: self.submit_on_push,
|
||||||
|
attach_pdf: self.attach_pdf,
|
||||||
|
auto_push_on_issue: self.auto_push_on_issue,
|
||||||
|
create_missing_customers: self.create_missing_customers,
|
||||||
|
last_detect_result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT: &str = "SELECT base_url, api_key, api_secret, extra_ca_pem, company, company_address, naming_mode,
|
||||||
|
naming_series, income_account, cost_center, cgst_account, sgst_account, utgst_account, igst_account,
|
||||||
|
tax_template_intra, tax_template_inter, payment_bank_account, tds_account, default_item_code, uom_map,
|
||||||
|
customer_group, territory, selling_price_list, submit_on_push, attach_pdf, auto_push_on_issue,
|
||||||
|
create_missing_customers, last_detect_result FROM erpnext_config WHERE id = 1";
|
||||||
|
|
||||||
|
pub fn load(conn: &Connection) -> Result<ErpnextConfig, String> {
|
||||||
|
conn.query_row(SELECT, [], |r| {
|
||||||
|
let naming: String = r.get(6)?;
|
||||||
|
let uom_json: String = r.get(19)?;
|
||||||
|
let mut uom_map = default_uom_map();
|
||||||
|
if let Ok(stored) = serde_json::from_str::<BTreeMap<String, String>>(&uom_json) {
|
||||||
|
uom_map.extend(stored.into_iter().filter(|(_, v)| !v.trim().is_empty()));
|
||||||
|
}
|
||||||
|
Ok(ErpnextConfig {
|
||||||
|
base_url: r.get(0)?,
|
||||||
|
api_key: r.get(1)?,
|
||||||
|
api_secret: Secret::new(r.get::<_, String>(2)?),
|
||||||
|
extra_ca_pem: r.get(3)?,
|
||||||
|
company: r.get(4)?,
|
||||||
|
company_address: r.get(5)?,
|
||||||
|
naming_mode: NamingMode::parse(&naming).unwrap_or(NamingMode::Mirror),
|
||||||
|
naming_series: r.get(7)?,
|
||||||
|
income_account: r.get(8)?,
|
||||||
|
cost_center: r.get(9)?,
|
||||||
|
cgst_account: r.get(10)?,
|
||||||
|
sgst_account: r.get(11)?,
|
||||||
|
utgst_account: r.get(12)?,
|
||||||
|
igst_account: r.get(13)?,
|
||||||
|
tax_template_intra: r.get(14)?,
|
||||||
|
tax_template_inter: r.get(15)?,
|
||||||
|
payment_bank_account: r.get(16)?,
|
||||||
|
tds_account: r.get(17)?,
|
||||||
|
default_item_code: r.get(18)?,
|
||||||
|
uom_map,
|
||||||
|
customer_group: r.get(20)?,
|
||||||
|
territory: r.get(21)?,
|
||||||
|
selling_price_list: r.get(22)?,
|
||||||
|
submit_on_push: r.get::<_, i64>(23)? != 0,
|
||||||
|
attach_pdf: r.get::<_, i64>(24)? != 0,
|
||||||
|
auto_push_on_issue: r.get::<_, i64>(25)? != 0,
|
||||||
|
create_missing_customers: r.get::<_, i64>(26)? != 0,
|
||||||
|
last_detect_result: r.get(27)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(conn: &Connection, c: &ErpnextConfig) -> Result<(), String> {
|
||||||
|
let uom_json = serde_json::to_string(&c.uom_map).map_err(|e| e.to_string())?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE erpnext_config SET base_url = ?1, api_key = ?2, api_secret = ?3, extra_ca_pem = ?4,
|
||||||
|
company = ?5, company_address = ?6, naming_mode = ?7, naming_series = ?8, income_account = ?9,
|
||||||
|
cost_center = ?10, cgst_account = ?11, sgst_account = ?12, utgst_account = ?13, igst_account = ?14,
|
||||||
|
tax_template_intra = ?15, tax_template_inter = ?16, payment_bank_account = ?17, tds_account = ?18,
|
||||||
|
default_item_code = ?19, uom_map = ?20, customer_group = ?21, territory = ?22,
|
||||||
|
selling_price_list = ?23, submit_on_push = ?24, attach_pdf = ?25, auto_push_on_issue = ?26,
|
||||||
|
create_missing_customers = ?27, last_detect_result = ?28,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
WHERE id = 1",
|
||||||
|
params![
|
||||||
|
c.base_url,
|
||||||
|
c.api_key,
|
||||||
|
c.api_secret.expose(),
|
||||||
|
c.extra_ca_pem,
|
||||||
|
c.company,
|
||||||
|
c.company_address,
|
||||||
|
c.naming_mode.as_str(),
|
||||||
|
c.naming_series,
|
||||||
|
c.income_account,
|
||||||
|
c.cost_center,
|
||||||
|
c.cgst_account,
|
||||||
|
c.sgst_account,
|
||||||
|
c.utgst_account,
|
||||||
|
c.igst_account,
|
||||||
|
c.tax_template_intra,
|
||||||
|
c.tax_template_inter,
|
||||||
|
c.payment_bank_account,
|
||||||
|
c.tds_account,
|
||||||
|
c.default_item_code,
|
||||||
|
uom_json,
|
||||||
|
c.customer_group,
|
||||||
|
c.territory,
|
||||||
|
c.selling_price_list,
|
||||||
|
c.submit_on_push as i64,
|
||||||
|
c.attach_pdf as i64,
|
||||||
|
c.auto_push_on_issue as i64,
|
||||||
|
c.create_missing_customers as i64,
|
||||||
|
c.last_detect_result,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
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());
|
||||||
|
serde_json::from_value(base).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db() -> Connection {
|
||||||
|
crate::db::open_in_memory().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_never_prints_the_secret() {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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_eq!(cfg.uom_for("hour"), "Hour");
|
||||||
|
assert_eq!(cfg.uom_for("session"), "Nos");
|
||||||
|
assert!(!cfg.api_secret.is_set());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_keeps_secret_server_side_only() {
|
||||||
|
let conn = db();
|
||||||
|
let stored = load(&conn).unwrap();
|
||||||
|
let cfg = input(serde_json::json!({ "apiSecret": "topsecret", "company": "Test Co", "submitOnPush": true }))
|
||||||
|
.resolve(&stored)
|
||||||
|
.unwrap();
|
||||||
|
save(&conn, &cfg).unwrap();
|
||||||
|
|
||||||
|
let back = load(&conn).unwrap();
|
||||||
|
assert_eq!(back.api_secret.expose(), "topsecret");
|
||||||
|
assert_eq!(back.base_url, "https://erp.example.com");
|
||||||
|
assert_eq!(back.company, "Test Co");
|
||||||
|
assert!(back.submit_on_push);
|
||||||
|
|
||||||
|
let view = serde_json::to_value(back.view()).unwrap();
|
||||||
|
assert_eq!(view["apiSecretSet"], true);
|
||||||
|
assert!(!view.to_string().contains("topsecret"));
|
||||||
|
assert!(view.get("apiSecret").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
save(&conn, &first).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();
|
||||||
|
assert_eq!(replace.api_secret.expose(), "two");
|
||||||
|
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();
|
||||||
|
save(&conn, &first).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());
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uom_map_merges_over_defaults() {
|
||||||
|
let cfg = input(serde_json::json!({ "uomMap": { "hour": "Hr", "unit": "" } }))
|
||||||
|
.resolve(&ErpnextConfig::default())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cfg.uom_for("hour"), "Hr");
|
||||||
|
assert_eq!(cfg.uom_for("unit"), "Nos");
|
||||||
|
assert_eq!(cfg.uom_for("minute"), "Minute");
|
||||||
|
assert_eq!(cfg.uom_for("unheard-of"), "Nos");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
//! Turning Frappe/ERPNext error responses into one readable message plus a machine-usable kind.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ErrorKind {
|
||||||
|
/// 401: wrong key/secret.
|
||||||
|
Auth,
|
||||||
|
/// 403: the user lacks a role or permission.
|
||||||
|
Permission,
|
||||||
|
NotFound,
|
||||||
|
/// 409 (or a `DuplicateEntryError`): the document already exists.
|
||||||
|
Duplicate,
|
||||||
|
/// 417 and other 4xx data rejections.
|
||||||
|
Validation,
|
||||||
|
RateLimit,
|
||||||
|
Server,
|
||||||
|
Timeout,
|
||||||
|
Network,
|
||||||
|
/// A setting is missing or invalid; nothing was sent.
|
||||||
|
Config,
|
||||||
|
/// The server answered, but not like a Frappe site.
|
||||||
|
Protocol,
|
||||||
|
/// The remote document exists with different data; Voiced never overwrites it.
|
||||||
|
Conflict,
|
||||||
|
/// Voiced refused before sending anything (a draft or cancelled invoice, an unsubmitted parent, bad local data).
|
||||||
|
Precondition,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorKind {
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ErrorKind::Auth => "Authentication failed",
|
||||||
|
ErrorKind::Permission => "Permission denied",
|
||||||
|
ErrorKind::NotFound => "Not found",
|
||||||
|
ErrorKind::Duplicate => "Already exists",
|
||||||
|
ErrorKind::Validation => "ERPNext rejected the data",
|
||||||
|
ErrorKind::RateLimit => "Rate limited",
|
||||||
|
ErrorKind::Server => "ERPNext server error",
|
||||||
|
ErrorKind::Timeout
|
||||||
|
| ErrorKind::Network
|
||||||
|
| ErrorKind::Config
|
||||||
|
| ErrorKind::Protocol
|
||||||
|
| ErrorKind::Conflict
|
||||||
|
| ErrorKind::Precondition => "",
|
||||||
|
ErrorKind::Other => "Request failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ErpError {
|
||||||
|
pub kind: ErrorKind,
|
||||||
|
pub status: Option<u16>,
|
||||||
|
pub message: String,
|
||||||
|
/// Frappe exception class (e.g. `DuplicateEntryError`) when the server named one.
|
||||||
|
pub exc_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ErpError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let label = self.kind.label();
|
||||||
|
match (label.is_empty(), self.status) {
|
||||||
|
(true, _) => f.write_str(&self.message),
|
||||||
|
(false, Some(status)) => write!(f, "{label} (HTTP {status}): {}", self.message),
|
||||||
|
(false, None) => write!(f, "{label}: {}", self.message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ErpError {}
|
||||||
|
|
||||||
|
impl From<ErpError> for String {
|
||||||
|
fn from(e: ErpError) -> String {
|
||||||
|
e.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErpError {
|
||||||
|
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||||
|
ErpError {
|
||||||
|
kind,
|
||||||
|
status: None,
|
||||||
|
message: message.into(),
|
||||||
|
exc_type: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorKind::Config, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn protocol(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorKind::Protocol, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the error for a non-2xx response.
|
||||||
|
pub fn from_response(status: u16, body: &str) -> Self {
|
||||||
|
let parsed = parse_error_body(body);
|
||||||
|
let mut kind = kind_for_status(status);
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every occurrence of the given secrets from the message.
|
||||||
|
pub fn redacted(mut self, secrets: &[&str]) -> Self {
|
||||||
|
for s in secrets.iter().filter(|s| s.len() >= 4) {
|
||||||
|
self.message = self.message.replace(s, "[redacted]");
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind_for_status(status: u16) -> ErrorKind {
|
||||||
|
match status {
|
||||||
|
401 => ErrorKind::Auth,
|
||||||
|
403 => ErrorKind::Permission,
|
||||||
|
404 => ErrorKind::NotFound,
|
||||||
|
409 => ErrorKind::Duplicate,
|
||||||
|
429 => ErrorKind::RateLimit,
|
||||||
|
400 | 417 | 422 => ErrorKind::Validation,
|
||||||
|
500..=599 => ErrorKind::Server,
|
||||||
|
_ => ErrorKind::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_status_text(status: u16) -> String {
|
||||||
|
let reason = reqwest::StatusCode::from_u16(status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.canonical_reason())
|
||||||
|
.unwrap_or("unexpected response");
|
||||||
|
format!("HTTP {status} {reason}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct ParsedError {
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub exc_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MESSAGE_CHARS: usize = 600;
|
||||||
|
|
||||||
|
/// Extracts the human message from a Frappe error body.
|
||||||
|
///
|
||||||
|
/// v2 puts `errors[]` first. v1 uses `_server_messages` (a JSON string holding a list of JSON strings,
|
||||||
|
/// each usually an object with an HTML `message`), then `_error_message`, then `exception` (with the
|
||||||
|
/// `frappe.exceptions.X: ` prefix dropped), then `exc_type`. A body that is not JSON (an nginx 502 page)
|
||||||
|
/// yields its stripped text, or nothing when that is empty.
|
||||||
|
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 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 messages: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(errors) = obj.get("errors").and_then(Value::as_array) {
|
||||||
|
for e in errors {
|
||||||
|
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);
|
||||||
|
if exc_type.is_none() {
|
||||||
|
exc_type = o
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.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()))
|
||||||
|
{
|
||||||
|
push_unique(&mut messages, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages.is_empty() {
|
||||||
|
if let Some(raw) = obj.get("_server_messages").and_then(Value::as_str) {
|
||||||
|
for m in server_messages(raw) {
|
||||||
|
push_unique(&mut messages, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
{
|
||||||
|
messages.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if messages.is_empty() {
|
||||||
|
if let Some(raw) = obj.get("exception").and_then(Value::as_str) {
|
||||||
|
let (ty, msg) = split_exception(raw);
|
||||||
|
if exc_type.is_none() {
|
||||||
|
exc_type = ty;
|
||||||
|
}
|
||||||
|
if !msg.is_empty() {
|
||||||
|
messages.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if messages.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if messages.is_empty() {
|
||||||
|
if let Some(t) = &exc_type {
|
||||||
|
messages.push(t.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
ParsedError {
|
||||||
|
message: non_empty(truncate(&messages.join("; "))),
|
||||||
|
exc_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_messages(raw: &str) -> Vec<String> {
|
||||||
|
let Ok(Value::Array(items)) = serde_json::from_str::<Value>(raw) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.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(),
|
||||||
|
_ => s.clone(),
|
||||||
|
},
|
||||||
|
Value::Object(o) => o
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let text = strip_html(&text);
|
||||||
|
(!text.is_empty()).then_some(text)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits `frappe.exceptions.ValidationError: Row 1: bad` into (`ValidationError`, `Row 1: bad`).
|
||||||
|
/// A string without a dotted-identifier prefix is returned whole.
|
||||||
|
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 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.contains('.')
|
||||||
|
{
|
||||||
|
// `frappe.exceptions.DoesNotExistError` with no message.
|
||||||
|
return (
|
||||||
|
first_line.rsplit('.').next().map(str::to_string),
|
||||||
|
String::new(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(None, strip_html(first_line))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_unique(list: &mut Vec<String>, text: String) {
|
||||||
|
if !text.is_empty() && !list.contains(&text) {
|
||||||
|
list.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(s: String) -> Option<String> {
|
||||||
|
(!s.is_empty()).then_some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str) -> String {
|
||||||
|
if s.chars().count() <= MAX_MESSAGE_CHARS {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let cut: String = s.chars().take(MAX_MESSAGE_CHARS).collect();
|
||||||
|
format!("{cut}...")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops tags, decodes the common entities and collapses whitespace.
|
||||||
|
pub fn strip_html(input: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(input.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
let mut tag = String::new();
|
||||||
|
for ch in input.chars() {
|
||||||
|
match ch {
|
||||||
|
'<' => {
|
||||||
|
in_tag = true;
|
||||||
|
tag.clear();
|
||||||
|
}
|
||||||
|
'>' 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"
|
||||||
|
) {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if in_tag => tag.push(ch),
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let decoded = out
|
||||||
|
.replace(" ", " ")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("&", "&");
|
||||||
|
decoded
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v1_server_messages_are_double_encoded_and_html_is_stripped() {
|
||||||
|
let inner1 = json!({ "message": "<b>Row 1</b>: Account <i>Sales</i> & more", "indicator": "red" }).to_string();
|
||||||
|
let inner2 = json!({ "message": "Second <br> problem" }).to_string();
|
||||||
|
let server_messages = serde_json::to_string(&vec![inner1, inner2]).unwrap();
|
||||||
|
let body = json!({
|
||||||
|
"exc_type": "ValidationError",
|
||||||
|
"exception": "frappe.exceptions.ValidationError: ignored because messages exist",
|
||||||
|
"_server_messages": server_messages,
|
||||||
|
})
|
||||||
|
.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.exc_type.as_deref(), Some("ValidationError"));
|
||||||
|
let err = ErpError::from_response(417, &body);
|
||||||
|
assert_eq!(err.kind, ErrorKind::Validation);
|
||||||
|
assert_eq!(err.status, Some(417));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v1_falls_back_to_error_message_then_exception_then_exc_type() {
|
||||||
|
let a = parse_error_body(&json!({ "_error_message": "Not <b>permitted</b>" }).to_string());
|
||||||
|
assert_eq!(a.message.as_deref(), Some("Not permitted"));
|
||||||
|
|
||||||
|
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.exc_type.as_deref(), Some("DuplicateEntryError"));
|
||||||
|
|
||||||
|
let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string());
|
||||||
|
assert_eq!(c.message.as_deref(), Some("PermissionError"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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")
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.exc_type, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v2_errors_array_is_joined() {
|
||||||
|
let body = json!({
|
||||||
|
"errors": [
|
||||||
|
{ "type": "ValidationError", "message": "Customer <b>X</b> is disabled", "exception": "frappe.exceptions.ValidationError: x" },
|
||||||
|
{ "type": "ValidationError", "exception": "frappe.exceptions.ValidationError: Row 2: rate missing" },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.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.exc_type.as_deref(), Some("ValidationError"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_json_bodies_use_stripped_text_or_status() {
|
||||||
|
let html = "<html><body><h1>502 Bad Gateway</h1><hr>nginx</body></html>";
|
||||||
|
let err = ErpError::from_response(502, html);
|
||||||
|
assert_eq!(err.kind, ErrorKind::Server);
|
||||||
|
assert_eq!(err.message, "502 Bad Gateway");
|
||||||
|
|
||||||
|
let empty = ErpError::from_response(503, "");
|
||||||
|
assert_eq!(empty.message, "HTTP 503 Service Unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn statuses_map_to_kinds() {
|
||||||
|
for (status, kind) in [
|
||||||
|
(401, ErrorKind::Auth),
|
||||||
|
(403, ErrorKind::Permission),
|
||||||
|
(404, ErrorKind::NotFound),
|
||||||
|
(409, ErrorKind::Duplicate),
|
||||||
|
(417, ErrorKind::Validation),
|
||||||
|
(429, ErrorKind::RateLimit),
|
||||||
|
(500, ErrorKind::Server),
|
||||||
|
(502, ErrorKind::Server),
|
||||||
|
(418, ErrorKind::Other),
|
||||||
|
] {
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
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"]);
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//! ERPNext / Frappe integration. All HTTP runs in Rust (see `client`); `mapping` is pure and
|
||||||
|
//! snapshot-tested; `config` owns the stored settings and the API secret.
|
||||||
|
|
||||||
|
// Parts of this module are only used by the later UI step.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
pub mod config;
|
||||||
|
pub mod discovery;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod mapping;
|
||||||
|
pub mod push;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod testutil;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod live_tests;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! A tiny HTTP/1.1 mock server for client tests (one request per connection, scripted or routed replies).
|
||||||
|
|
||||||
|
use super::client::{ClientOptions, ErpClient};
|
||||||
|
use super::config::Secret;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
pub struct Reply {
|
||||||
|
pub status: u16,
|
||||||
|
pub headers: Vec<(&'static str, String)>,
|
||||||
|
pub body: String,
|
||||||
|
/// Pause before answering, to trigger the read timeout.
|
||||||
|
pub delay_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reply(status: u16, body: Value) -> Reply {
|
||||||
|
Reply {
|
||||||
|
status,
|
||||||
|
headers: vec![],
|
||||||
|
body: body.to_string(),
|
||||||
|
delay_ms: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Mock {
|
||||||
|
pub base: String,
|
||||||
|
pub hits: Arc<AtomicUsize>,
|
||||||
|
/// Raw requests (head and body) in arrival order.
|
||||||
|
pub requests: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves the scripted replies in order; the last one repeats.
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answers every request with `handler(request_index, request_line)`.
|
||||||
|
pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'static) -> Mock {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let hits = Arc::new(AtomicUsize::new(0));
|
||||||
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let (h, r) = (hits.clone(), requests.clone());
|
||||||
|
let handler = Arc::new(handler);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
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();
|
||||||
|
let mut chunk = [0u8; 2048];
|
||||||
|
loop {
|
||||||
|
let n = sock.read(&mut chunk).await.unwrap_or(0);
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
if let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||||
|
let head = String::from_utf8_lossy(&buf[..end]).to_string();
|
||||||
|
let want = head
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| {
|
||||||
|
l.to_ascii_lowercase()
|
||||||
|
.strip_prefix("content-length:")
|
||||||
|
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
if buf.len() >= end + 4 + want {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let raw = String::from_utf8_lossy(&buf).to_string();
|
||||||
|
let request_line = raw.lines().next().unwrap_or("").to_string();
|
||||||
|
r.lock().unwrap().push(raw);
|
||||||
|
let i = h.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let rep = handler(i, &request_line);
|
||||||
|
if rep.delay_ms > 0 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(rep.delay_ms)).await;
|
||||||
|
}
|
||||||
|
let mut out = format!(
|
||||||
|
"HTTP/1.1 {} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n",
|
||||||
|
rep.status,
|
||||||
|
rep.body.len()
|
||||||
|
);
|
||||||
|
for (k, v) in &rep.headers {
|
||||||
|
out.push_str(&format!("{k}: {v}\r\n"));
|
||||||
|
}
|
||||||
|
out.push_str("\r\n");
|
||||||
|
out.push_str(&rep.body);
|
||||||
|
let _ = sock.write_all(out.as_bytes()).await;
|
||||||
|
let _ = sock.shutdown().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Mock {
|
||||||
|
base: format!("http://127.0.0.1:{port}"),
|
||||||
|
hits,
|
||||||
|
requests,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short timeouts and near-zero backoff so retry tests run in milliseconds.
|
||||||
|
pub fn fast_opts() -> ClientOptions {
|
||||||
|
ClientOptions {
|
||||||
|
connect_timeout: Duration::from_secs(2),
|
||||||
|
read_timeout: Duration::from_millis(250),
|
||||||
|
total_timeout: Duration::from_secs(5),
|
||||||
|
max_retries: 3,
|
||||||
|
backoff_base: Duration::from_millis(1),
|
||||||
|
max_backoff: Duration::from_millis(5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client(mock: &Mock) -> ErpClient {
|
||||||
|
ErpClient::new(
|
||||||
|
&mock.base,
|
||||||
|
"thekey",
|
||||||
|
&Secret::new("thesecret"),
|
||||||
|
"",
|
||||||
|
fast_opts(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! Outbound integrations. `InvoiceSink` is the seam between Voiced and a system that receives issued
|
||||||
|
//! invoices (ERPNext now; a webhook, Zoho or a Tally import later). Everything target-specific lives in a
|
||||||
|
//! submodule; the rest of the app only talks to this trait.
|
||||||
|
|
||||||
|
// Parts of the sink API are only used by the later UI step.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod erpnext;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
/// What a sink reports after accepting an invoice.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PushedInvoice {
|
||||||
|
/// The document name on the remote system.
|
||||||
|
pub remote_name: String,
|
||||||
|
/// 0 draft, 1 submitted.
|
||||||
|
pub remote_docstatus: i64,
|
||||||
|
/// False when the remote already had this invoice (an idempotent re-push).
|
||||||
|
pub created: bool,
|
||||||
|
/// Non-fatal notes, e.g. "pushed without the PDF because the invoice is not archived".
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PushRequest {
|
||||||
|
pub invoice_id: i64,
|
||||||
|
/// Submit on the remote system instead of leaving a draft for review; `None` follows the sink's setting.
|
||||||
|
pub submit: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PaymentRequest {
|
||||||
|
pub payment_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PushedPayment {
|
||||||
|
pub remote_name: String,
|
||||||
|
/// False when the payment had already been sent (an idempotent re-push).
|
||||||
|
pub created: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The locally recorded sync state of one invoice. `status` is `none` until something was pushed, then
|
||||||
|
/// `synced`, `error` or `conflict`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SyncStatus {
|
||||||
|
pub invoice_id: i64,
|
||||||
|
pub status: String,
|
||||||
|
pub remote_name: String,
|
||||||
|
/// 0 draft, 1 submitted.
|
||||||
|
pub remote_docstatus: i64,
|
||||||
|
pub last_error: String,
|
||||||
|
pub synced_at: Option<String>,
|
||||||
|
/// The archived PDF was uploaded to the remote document.
|
||||||
|
pub attached: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncStatus {
|
||||||
|
pub fn none(invoice_id: i64) -> Self {
|
||||||
|
SyncStatus {
|
||||||
|
invoice_id,
|
||||||
|
status: "none".into(),
|
||||||
|
remote_name: String::new(),
|
||||||
|
remote_docstatus: 0,
|
||||||
|
last_error: String::new(),
|
||||||
|
synced_at: None,
|
||||||
|
attached: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait InvoiceSink: Send + Sync {
|
||||||
|
/// Stable identifier used in the sync table (`erpnext`, ...).
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
|
||||||
|
fn push_invoice<'a>(
|
||||||
|
&'a self,
|
||||||
|
request: PushRequest,
|
||||||
|
) -> impl Future<Output = Result<PushedInvoice, String>> + Send + 'a;
|
||||||
|
|
||||||
|
fn push_payment<'a>(
|
||||||
|
&'a self,
|
||||||
|
request: PaymentRequest,
|
||||||
|
) -> impl Future<Output = Result<PushedPayment, String>> + Send + 'a;
|
||||||
|
|
||||||
|
/// The locally recorded state; never touches the network.
|
||||||
|
fn status(&self, invoice_id: i64) -> Result<SyncStatus, String>;
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
mod db;
|
||||||
mod gst;
|
mod gst;
|
||||||
|
mod integrations;
|
||||||
mod logo;
|
mod logo;
|
||||||
mod models;
|
mod models;
|
||||||
mod selftest;
|
mod selftest;
|
||||||
|
mod units;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -37,40 +39,87 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
let data_dir = match app.path().app_data_dir() {
|
let data_dir = match app.path().app_data_dir() {
|
||||||
Ok(dir) => dir,
|
Ok(dir) => dir,
|
||||||
Err(e) => {
|
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());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
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());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
let local_data_dir = match app.path().app_local_data_dir() {
|
let local_data_dir = match app.path().app_local_data_dir() {
|
||||||
Ok(dir) => dir,
|
Ok(dir) => dir,
|
||||||
Err(e) => {
|
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());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = std::fs::create_dir_all(&local_data_dir) {
|
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());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
// 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
commands::backup::ApplyOutcome::Failed { message } => {
|
||||||
|
eprintln!("Backup restore failed: {message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(e) => {
|
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());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) {
|
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());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
// Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries.
|
// Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries.
|
||||||
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
|
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
|
||||||
eprintln!("Could not derive the logo images: {e}");
|
eprintln!("Could not derive the logo images: {e}");
|
||||||
}
|
}
|
||||||
|
commands::backup::spawn_auto_backup(dirs);
|
||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
db: Mutex::new(conn),
|
db: Mutex::new(conn),
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -80,6 +129,9 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
// After "Restart" in the Data tab the new process waits for the old one (and its single-instance lock) to go.
|
||||||
|
commands::backup::wait_if_relaunched();
|
||||||
|
|
||||||
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
||||||
// combinations. Respect an explicit user setting, otherwise turn it off.
|
// combinations. Respect an explicit user setting, otherwise turn it off.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -121,6 +173,9 @@ pub fn run() {
|
|||||||
commands::clients::list_clients,
|
commands::clients::list_clients,
|
||||||
commands::clients::save_client,
|
commands::clients::save_client,
|
||||||
commands::clients::delete_client,
|
commands::clients::delete_client,
|
||||||
|
commands::presets::list_item_presets,
|
||||||
|
commands::presets::save_item_preset,
|
||||||
|
commands::presets::delete_item_preset,
|
||||||
commands::invoice::peek_next_invoice_number,
|
commands::invoice::peek_next_invoice_number,
|
||||||
commands::invoice::issue_invoice,
|
commands::invoice::issue_invoice,
|
||||||
commands::invoice::cancel_invoice,
|
commands::invoice::cancel_invoice,
|
||||||
@@ -130,6 +185,17 @@ pub fn run() {
|
|||||||
commands::invoice::list_drafts,
|
commands::invoice::list_drafts,
|
||||||
commands::invoice::get_draft,
|
commands::invoice::get_draft,
|
||||||
commands::invoice::delete_draft,
|
commands::invoice::delete_draft,
|
||||||
|
commands::payments::record_payment,
|
||||||
|
commands::payments::list_payments,
|
||||||
|
commands::payments::delete_payment,
|
||||||
|
commands::backup::create_backup,
|
||||||
|
commands::backup::restore_backup,
|
||||||
|
commands::backup::cancel_pending_restore,
|
||||||
|
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::import_asset,
|
||||||
commands::assets::save_asset_bytes,
|
commands::assets::save_asset_bytes,
|
||||||
commands::assets::read_asset_data_uri,
|
commands::assets::read_asset_data_uri,
|
||||||
@@ -150,6 +216,16 @@ pub fn run() {
|
|||||||
commands::files::get_last_export_dir,
|
commands::files::get_last_export_dir,
|
||||||
commands::files::reveal_in_folder,
|
commands::files::reveal_in_folder,
|
||||||
commands::files::open_file,
|
commands::files::open_file,
|
||||||
|
commands::erpnext::erpnext_get_config,
|
||||||
|
commands::erpnext::erpnext_save_config,
|
||||||
|
commands::erpnext::erpnext_test_connection,
|
||||||
|
commands::erpnext::erpnext_load_options,
|
||||||
|
commands::erpnext::erpnext_push_invoice,
|
||||||
|
commands::erpnext::erpnext_push_invoices,
|
||||||
|
commands::erpnext::erpnext_push_payment,
|
||||||
|
commands::erpnext::erpnext_sync_status,
|
||||||
|
commands::erpnext::erpnext_sync_statuses,
|
||||||
|
commands::erpnext::erpnext_open_url,
|
||||||
selftest::selftest_config,
|
selftest::selftest_config,
|
||||||
selftest::selftest_report,
|
selftest::selftest_report,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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
|
/// 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) {
|
fn quantize_colour(img: &mut RgbaImage) {
|
||||||
for p in img.pixels_mut() {
|
for p in img.pixels_mut() {
|
||||||
for c in 0..3 {
|
for c in 0..3 {
|
||||||
@@ -224,7 +224,8 @@ fn quantize_colour(img: &mut RgbaImage) {
|
|||||||
|
|
||||||
fn encode_png(img: &DynamicImage) -> Result<Vec<u8>, LogoError> {
|
fn encode_png(img: &DynamicImage) -> Result<Vec<u8>, LogoError> {
|
||||||
let mut buf = Vec::new();
|
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)
|
img.write_with_encoder(encoder)
|
||||||
.map_err(|e| LogoError::Encode(e.to_string()))?;
|
.map_err(|e| LogoError::Encode(e.to_string()))?;
|
||||||
Ok(buf)
|
Ok(buf)
|
||||||
@@ -360,7 +361,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn decode_png(bytes: &[u8]) -> RgbaImage {
|
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]
|
#[test]
|
||||||
@@ -427,7 +430,8 @@ mod tests {
|
|||||||
assert!((38..=42).contains(&m.height), "{}", m.height);
|
assert!((38..=42).contains(&m.height), "{}", m.height);
|
||||||
assert!(out.knockout_png.is_none());
|
assert!(out.knockout_png.is_none());
|
||||||
// The input pixels are not edited: the printed corners are still the logo colour, not transparent.
|
// 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());
|
assert!(!printed.color().has_alpha());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +463,10 @@ mod tests {
|
|||||||
// Right magic, broken body.
|
// Right magic, broken body.
|
||||||
let mut truncated = png_bytes(&alpha_logo(20, 20, (2, 2, 5, 5), [0; 3]));
|
let mut truncated = png_bytes(&alpha_logo(20, 20, (2, 2, 5, 5), [0; 3]));
|
||||||
truncated.truncate(30);
|
truncated.truncate(30);
|
||||||
assert!(matches!(process_logo(&truncated), Err(LogoError::Decode(_))));
|
assert!(matches!(
|
||||||
|
process_logo(&truncated),
|
||||||
|
Err(LogoError::Decode(_))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -470,14 +477,23 @@ mod tests {
|
|||||||
|
|
||||||
// A blank image 8193 px wide compresses to a few KB, so it passes the byte cap.
|
// A blank image 8193 px wide compresses to a few KB, so it passes the byte cap.
|
||||||
let wide = RgbaImage::new(8193, 2);
|
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]
|
#[test]
|
||||||
fn blank_images_have_no_content() {
|
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]));
|
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]
|
#[test]
|
||||||
@@ -508,9 +524,15 @@ mod tests {
|
|||||||
assert!((m.ink_density - 0.52).abs() < 1e-9, "{}", m.ink_density);
|
assert!((m.ink_density - 0.52).abs() < 1e-9, "{}", m.ink_density);
|
||||||
|
|
||||||
let square = alpha_logo(50, 50, (10, 10, 30, 30), [0; 3]);
|
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]);
|
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(2.49), LogoKind::Mark);
|
||||||
assert_eq!(kind_for(0.6), LogoKind::Tall);
|
assert_eq!(kind_for(0.6), LogoKind::Tall);
|
||||||
}
|
}
|
||||||
@@ -535,15 +557,24 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sample_logo_is_trimmed_and_small() {
|
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 bytes = std::fs::read(path).unwrap();
|
||||||
let out = process_logo(&bytes).unwrap();
|
let out = process_logo(&bytes).unwrap();
|
||||||
let m = &out.metrics;
|
let m = &out.metrics;
|
||||||
assert_eq!((m.source_width, m.source_height), (2172, 724));
|
assert_eq!((m.source_width, m.source_height), (2172, 724));
|
||||||
assert!((4.5..=4.7).contains(&m.aspect), "aspect {}", m.aspect);
|
assert!((5.3..=5.6).contains(&m.aspect), "aspect {}", m.aspect);
|
||||||
assert!((0.40..=0.46).contains(&m.ink_density), "density {}", m.ink_density);
|
assert!(
|
||||||
|
(0.50..=0.60).contains(&m.ink_density),
|
||||||
|
"density {}",
|
||||||
|
m.ink_density
|
||||||
|
);
|
||||||
assert_eq!(m.kind, LogoKind::Wordmark);
|
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());
|
assert!(out.knockout_png.is_some());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,50 @@ pub struct Client {
|
|||||||
pub po_number: String,
|
pub po_number: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub address_line1: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub address_line2: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub city: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pincode: String,
|
||||||
|
/// registered_regular | unregistered | composition | sez | overseas. Informational only.
|
||||||
|
#[serde(default = "default_gst_category")]
|
||||||
|
pub gst_category: String,
|
||||||
|
/// Notes / payment terms text copied onto a new invoice when this client is picked.
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_notes: String,
|
||||||
|
/// None = use the settings default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub payment_terms_days: Option<i64>,
|
||||||
|
/// Number of invoices that reference this client; read-only, filled by list_clients.
|
||||||
|
#[serde(default)]
|
||||||
|
pub invoice_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_gst_category() -> String {
|
||||||
|
"unregistered".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ItemPreset {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hsn_sac: String,
|
||||||
|
/// "fixed" = rate_paise is the line amount; "rate" = rate_paise is the rate per unit.
|
||||||
|
#[serde(default = "default_mode")]
|
||||||
|
pub mode: String,
|
||||||
|
#[serde(default = "default_unit")]
|
||||||
|
pub unit: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rate_paise: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sort_order: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -221,6 +265,34 @@ pub struct Invoice {
|
|||||||
pub archived_fingerprint: Option<String>,
|
pub archived_fingerprint: Option<String>,
|
||||||
pub archived_at: Option<String>,
|
pub archived_at: Option<String>,
|
||||||
pub items: Vec<InvoiceItem>,
|
pub items: Vec<InvoiceItem>,
|
||||||
|
/// Derived from the payments table (never stored on the invoice): see commands::payments.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub payment: PaymentSummary,
|
||||||
|
/// `total` in integer paise.
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_paise: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What has been received against an invoice. All amounts are integer paise; `payment_status` is
|
||||||
|
/// unpaid | partially_paid | paid | overdue, or `none` for an invoice that takes no payments.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PaymentSummary {
|
||||||
|
pub paid_paise: i64,
|
||||||
|
pub tds_paise: i64,
|
||||||
|
pub balance_paise: i64,
|
||||||
|
pub payment_status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PaymentSummary {
|
||||||
|
fn default() -> Self {
|
||||||
|
PaymentSummary {
|
||||||
|
paid_paise: 0,
|
||||||
|
tds_paise: 0,
|
||||||
|
balance_paise: 0,
|
||||||
|
payment_status: "none".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -234,6 +306,10 @@ pub struct InvoiceSummary {
|
|||||||
pub total: f64,
|
pub total: f64,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub po_number: String,
|
||||||
|
pub total_paise: i64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub payment: PaymentSummary,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ use std::path::{Path, PathBuf};
|
|||||||
const ENV_VAR: &str = "VOICED_SELFTEST_OUT";
|
const ENV_VAR: &str = "VOICED_SELFTEST_OUT";
|
||||||
|
|
||||||
fn configured_path() -> Option<PathBuf> {
|
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> {
|
fn write_report(path: &Path, json: &str) -> Result<(), String> {
|
||||||
if let Some(parent) = path.parent() {
|
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())
|
file.write_all(json.as_bytes())
|
||||||
.and_then(|()| file.sync_all())
|
.and_then(|()| file.sync_all())
|
||||||
.map_err(|e| format!("Could not write the report: {e}"))
|
.map_err(|e| format!("Could not write the report: {e}"))
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Voiced",
|
"productName": "Voiced",
|
||||||
"version": "0.1.0",
|
"version": "1.2.0",
|
||||||
"identifier": "com.bongbetic.voiced",
|
"identifier": "com.bongbetic.voiced",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -6,14 +6,30 @@ import { AppShell, type AppView } from "./components/AppShell";
|
|||||||
import Onboarding from "./views/Onboarding";
|
import Onboarding from "./views/Onboarding";
|
||||||
import NewInvoice from "./views/NewInvoice";
|
import NewInvoice from "./views/NewInvoice";
|
||||||
import InvoiceHistory from "./views/InvoiceHistory";
|
import InvoiceHistory from "./views/InvoiceHistory";
|
||||||
import SeriesSettings from "./views/SeriesSettings";
|
import InvoiceDetail from "./views/InvoiceDetail";
|
||||||
|
import Clients from "./views/Clients";
|
||||||
|
import { ToastProvider, useToast } from "./components/ToastProvider";
|
||||||
import AppSettings from "./views/AppSettings";
|
import AppSettings from "./views/AppSettings";
|
||||||
|
import { requestSettingsTab } from "./lib/settingsTab";
|
||||||
|
import { applyDocumentTheme, cachedTheme, normalizeTheme } from "./lib/theme";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<AppInner />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppInner() {
|
||||||
|
const toast = useToast();
|
||||||
const [settings, setSettings] = useState<Settings | null>(null);
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [view, setView] = useState<AppView>("new");
|
const [view, setView] = useState<AppView>("new");
|
||||||
|
const [detailId, setDetailId] = useState<number | null>(null);
|
||||||
|
// A draft made by "Duplicate" that the New invoice form should open once it is shown.
|
||||||
|
const [openDraftId, setOpenDraftId] = useState<number | null>(null);
|
||||||
|
|
||||||
const refreshSettings = useCallback(async () => {
|
const refreshSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -31,9 +47,15 @@ export default function App() {
|
|||||||
void refreshSettings();
|
void refreshSettings();
|
||||||
}, [refreshSettings]);
|
}, [refreshSettings]);
|
||||||
|
|
||||||
|
// Before settings load (and on the error screen) use the cached theme so the window never flashes white.
|
||||||
|
const theme = settings ? normalizeTheme(settings.theme) : cachedTheme();
|
||||||
|
useEffect(() => {
|
||||||
|
applyDocumentTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Theme theme="g10">
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<div className="voiced-empty">
|
<div className="voiced-empty">
|
||||||
<Loading description="Loading Voiced" withOverlay={false} small />
|
<Loading description="Loading Voiced" withOverlay={false} small />
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +65,7 @@ export default function App() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Theme theme="g10">
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<div className="voiced-page">
|
<div className="voiced-page">
|
||||||
<InlineNotification
|
<InlineNotification
|
||||||
kind="error"
|
kind="error"
|
||||||
@@ -59,11 +81,9 @@ export default function App() {
|
|||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
const theme = settings.theme === "g100" ? "g100" : "g10";
|
|
||||||
|
|
||||||
if (!settings.onboarded) {
|
if (!settings.onboarded) {
|
||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<Onboarding
|
<Onboarding
|
||||||
initial={settings}
|
initial={settings}
|
||||||
onDone={(next) => {
|
onDone={(next) => {
|
||||||
@@ -75,31 +95,61 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openIntegrations = () => {
|
||||||
|
requestSettingsTab("integrations");
|
||||||
|
setView("settings");
|
||||||
|
};
|
||||||
|
|
||||||
const toggleTheme = async () => {
|
const toggleTheme = async () => {
|
||||||
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
|
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
|
||||||
setSettings(next);
|
setSettings(next);
|
||||||
try {
|
try {
|
||||||
await api.saveSettings(next);
|
await api.saveSettings(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
toast.error("Could not save the theme", String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
<AppShell view={view} onNavigate={setView} theme={theme} onToggleTheme={toggleTheme}>
|
||||||
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
||||||
<NewInvoice
|
<NewInvoice
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onSettingsChange={refreshSettings}
|
onSettingsChange={refreshSettings}
|
||||||
active={view === "new"}
|
active={view === "new"}
|
||||||
onActivate={() => setView("new")}
|
onActivate={() => setView("new")}
|
||||||
|
openDraftId={openDraftId}
|
||||||
|
onDraftOpened={() => setOpenDraftId(null)}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
/>
|
/>
|
||||||
<div hidden={view !== "invoices"}>
|
<div hidden={view !== "invoices"}>
|
||||||
<InvoiceHistory settings={settings} active={view === "invoices"} />
|
<InvoiceHistory
|
||||||
|
settings={settings}
|
||||||
|
active={view === "invoices"}
|
||||||
|
onOpenSettings={openIntegrations}
|
||||||
|
onOpen={(id) => {
|
||||||
|
setDetailId(id);
|
||||||
|
setView("invoice");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div hidden={view !== "series"}>
|
{view === "invoice" && detailId !== null ? (
|
||||||
<SeriesSettings active={view === "series"} />
|
<InvoiceDetail
|
||||||
|
key={detailId}
|
||||||
|
invoiceId={detailId}
|
||||||
|
settings={settings}
|
||||||
|
onBack={() => setView("invoices")}
|
||||||
|
onOpenSettings={openIntegrations}
|
||||||
|
onDuplicated={(draftId) => {
|
||||||
|
setOpenDraftId(draftId);
|
||||||
|
setView("new");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div hidden={view !== "clients"}>
|
||||||
|
<Clients active={view === "clients"} />
|
||||||
</div>
|
</div>
|
||||||
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import {
|
|||||||
import { Asleep, Light } from "@carbon/icons-react";
|
import { Asleep, Light } from "@carbon/icons-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export type AppView = "new" | "invoices" | "series" | "settings";
|
/** "invoice" is the detail page of one invoice; it is reached from "invoices" and highlights that tab. */
|
||||||
|
export type AppView = "new" | "invoices" | "invoice" | "clients" | "settings";
|
||||||
|
|
||||||
const NAV: { id: AppView; label: string }[] = [
|
const NAV: { id: Exclude<AppView, "invoice">; label: string }[] = [
|
||||||
{ id: "new", label: "New invoice" },
|
{ id: "new", label: "New invoice" },
|
||||||
{ id: "invoices", label: "Invoices" },
|
{ id: "invoices", label: "Invoices" },
|
||||||
{ id: "series", label: "Series" },
|
{ id: "clients", label: "Clients" },
|
||||||
{ id: "settings", label: "Settings" },
|
{ id: "settings", label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
|||||||
{NAV.map((item) => (
|
{NAV.map((item) => (
|
||||||
<HeaderMenuItem
|
<HeaderMenuItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
isCurrentPage={view === item.id}
|
isCurrentPage={view === item.id || (view === "invoice" && item.id === "invoices")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onNavigate(item.id);
|
onNavigate(item.id);
|
||||||
@@ -60,7 +61,11 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
|||||||
))}
|
))}
|
||||||
</HeaderNavigation>
|
</HeaderNavigation>
|
||||||
<HeaderGlobalBar>
|
<HeaderGlobalBar>
|
||||||
<HeaderGlobalAction aria-label="Toggle colour theme" onClick={onToggleTheme}>
|
<HeaderGlobalAction
|
||||||
|
aria-label={theme === "g100" ? "Switch to light theme" : "Switch to dark theme"}
|
||||||
|
tooltipAlignment="end"
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
>
|
||||||
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
||||||
</HeaderGlobalAction>
|
</HeaderGlobalAction>
|
||||||
</HeaderGlobalBar>
|
</HeaderGlobalBar>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState, type RefObject } from "react";
|
||||||
|
import { Column, Grid, Modal, TextInput, Toggle } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { BankAccount } from "../lib/types";
|
||||||
|
import { ifscError } from "../lib/validators";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
export const blankBank = (): BankAccount => ({
|
||||||
|
id: null,
|
||||||
|
label: "",
|
||||||
|
bankName: "",
|
||||||
|
accountName: "",
|
||||||
|
accountNo: "",
|
||||||
|
branch: "",
|
||||||
|
ifsc: "",
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BankAccountModalProps {
|
||||||
|
open: boolean;
|
||||||
|
/** The account to edit; null opens a blank one. */
|
||||||
|
bank: BankAccount | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (bank: BankAccount) => void;
|
||||||
|
/** Focus returns here when the modal closes. */
|
||||||
|
launcherRef?: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BankAccountModal({ open, bank, onClose, onSaved, launcherRef }: BankAccountModalProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [form, setForm] = useState<BankAccount>(blankBank());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setForm(bank ? { ...bank } : blankBank());
|
||||||
|
setBusy(false);
|
||||||
|
setTouched(false);
|
||||||
|
}, [open, bank]);
|
||||||
|
|
||||||
|
const set = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const bankNameProblem = form.bankName.trim() ? undefined : "Bank name is required";
|
||||||
|
const accountNoProblem = form.accountNo.trim() ? undefined : "Account number is required";
|
||||||
|
const ifscProblem = ifscError(form.ifsc);
|
||||||
|
const blocking = bankNameProblem ?? accountNoProblem ?? ifscProblem;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setTouched(true);
|
||||||
|
if (blocking) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const saved = await api.saveBank({
|
||||||
|
...form,
|
||||||
|
bankName: form.bankName.trim(),
|
||||||
|
accountNo: form.accountNo.trim(),
|
||||||
|
ifsc: form.ifsc.trim().toUpperCase(),
|
||||||
|
});
|
||||||
|
toast.success(form.id === null ? "Bank account added" : "Bank account saved", saved.bankName);
|
||||||
|
onSaved(saved);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not save bank account", String(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
modalHeading={bank?.id ? "Edit bank account" : "New bank account"}
|
||||||
|
primaryButtonText={busy ? "Saving…" : "Save account"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={busy}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onRequestSubmit={() => void submit()}
|
||||||
|
launcherButtonRef={launcherRef}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Grid narrow>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-label"
|
||||||
|
labelText="Label (optional)"
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => set("label", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-bankName"
|
||||||
|
labelText="Bank name"
|
||||||
|
value={form.bankName}
|
||||||
|
onChange={(e) => set("bankName", e.target.value)}
|
||||||
|
invalid={touched && Boolean(bankNameProblem)}
|
||||||
|
invalidText={bankNameProblem}
|
||||||
|
data-modal-primary-focus
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-accountName"
|
||||||
|
labelText="Account name"
|
||||||
|
value={form.accountName}
|
||||||
|
onChange={(e) => set("accountName", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-accountNo"
|
||||||
|
labelText="Account number"
|
||||||
|
value={form.accountNo}
|
||||||
|
onChange={(e) => set("accountNo", e.target.value)}
|
||||||
|
invalid={touched && Boolean(accountNoProblem)}
|
||||||
|
invalidText={accountNoProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-branch"
|
||||||
|
labelText="Branch"
|
||||||
|
value={form.branch}
|
||||||
|
onChange={(e) => set("branch", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-ifsc"
|
||||||
|
labelText="IFSC"
|
||||||
|
value={form.ifsc}
|
||||||
|
onChange={(e) => set("ifsc", e.target.value.toUpperCase())}
|
||||||
|
invalid={Boolean(ifscProblem)}
|
||||||
|
invalidText={ifscProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<Toggle
|
||||||
|
id="bm-default"
|
||||||
|
labelText="Use as default account"
|
||||||
|
toggled={form.isDefault}
|
||||||
|
onToggle={(value) => set("isDefault", value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useEffect, useState, type RefObject } from "react";
|
||||||
|
import { Column, Grid, InlineNotification, Modal, Select, SelectItem, TextArea, TextInput } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import {
|
||||||
|
GST_CATEGORY_LABELS,
|
||||||
|
INDIAN_STATES,
|
||||||
|
blankClient,
|
||||||
|
type Client,
|
||||||
|
type GstCategory,
|
||||||
|
} from "../lib/types";
|
||||||
|
import { composeAddress, pincodeError, splitLegacyAddress } from "../lib/clientAddress";
|
||||||
|
import { clientGstinStateError } from "../lib/validators";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
interface ClientModalProps {
|
||||||
|
open: boolean;
|
||||||
|
/** The client to edit; null opens a blank one. */
|
||||||
|
client: Client | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (client: Client) => void;
|
||||||
|
/** Focus returns here when the modal closes. */
|
||||||
|
launcherRef?: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills the structured address from the legacy free-text one when a client predates the new fields. */
|
||||||
|
export function prepareForEdit(client: Client): { form: Client; splitFromLegacy: boolean } {
|
||||||
|
const structured = client.addressLine1 || client.addressLine2 || client.city || client.pincode;
|
||||||
|
if (structured || !client.address.trim()) return { form: client, splitFromLegacy: false };
|
||||||
|
return { form: { ...client, ...splitLegacyAddress(client.address) }, splitFromLegacy: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientModal({ open, client, onClose, onSaved, launcherRef }: ClientModalProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [form, setForm] = useState<Client>(blankClient());
|
||||||
|
const [split, setSplit] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const prepared = prepareForEdit(client ?? blankClient());
|
||||||
|
setForm(prepared.form);
|
||||||
|
setSplit(prepared.splitFromLegacy);
|
||||||
|
setBusy(false);
|
||||||
|
setTouched(false);
|
||||||
|
}, [open, client]);
|
||||||
|
|
||||||
|
const set = <K extends keyof Client>(key: K, value: Client[K]) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const nameProblem = form.name.trim() ? undefined : "Client name is required";
|
||||||
|
const gstinProblem = clientGstinStateError(form.gstin, form.stateCode);
|
||||||
|
const pinProblem = pincodeError(form.pincode);
|
||||||
|
const termsProblem =
|
||||||
|
form.paymentTermsDays !== null && (form.paymentTermsDays < 0 || form.paymentTermsDays > 365)
|
||||||
|
? "Between 0 and 365 days"
|
||||||
|
: undefined;
|
||||||
|
const blocking = nameProblem ?? gstinProblem ?? pinProblem ?? termsProblem;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setTouched(true);
|
||||||
|
if (blocking) return;
|
||||||
|
setBusy(true);
|
||||||
|
const structured = form.addressLine1 || form.addressLine2 || form.city || form.pincode;
|
||||||
|
const toSave: Client = {
|
||||||
|
...form,
|
||||||
|
name: form.name.trim(),
|
||||||
|
gstin: form.gstin.trim().toUpperCase(),
|
||||||
|
address: structured
|
||||||
|
? composeAddress({
|
||||||
|
line1: form.addressLine1,
|
||||||
|
line2: form.addressLine2,
|
||||||
|
city: form.city,
|
||||||
|
stateCode: form.stateCode,
|
||||||
|
pincode: form.pincode,
|
||||||
|
})
|
||||||
|
: form.address,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const saved = await api.saveClient(toSave);
|
||||||
|
toast.success(toSave.id === null ? "Client added" : "Client updated", saved.name);
|
||||||
|
onSaved(saved);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not save client", String(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
modalHeading={client?.id ? "Edit client" : "New client"}
|
||||||
|
primaryButtonText={busy ? "Saving…" : "Save client"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={busy}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onRequestSubmit={() => void submit()}
|
||||||
|
launcherButtonRef={launcherRef}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Grid narrow>
|
||||||
|
{split ? (
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<InlineNotification
|
||||||
|
kind="info"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="Address split automatically"
|
||||||
|
subtitle="This client has a free-text address. Check the lines below before saving."
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
) : null}
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-name"
|
||||||
|
labelText="Client name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
invalid={touched && Boolean(nameProblem)}
|
||||||
|
invalidText={nameProblem}
|
||||||
|
data-modal-primary-focus
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<Select
|
||||||
|
id="cm-category"
|
||||||
|
labelText="GST category"
|
||||||
|
value={form.gstCategory}
|
||||||
|
onChange={(e) => set("gstCategory", e.target.value as GstCategory)}
|
||||||
|
>
|
||||||
|
{Object.entries(GST_CATEGORY_LABELS).map(([value, text]) => (
|
||||||
|
<SelectItem key={value} value={value} text={text} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-gstin"
|
||||||
|
labelText="GSTIN (optional)"
|
||||||
|
value={form.gstin}
|
||||||
|
onChange={(e) => set("gstin", e.target.value.toUpperCase())}
|
||||||
|
invalid={Boolean(form.gstin.trim()) && Boolean(gstinProblem)}
|
||||||
|
invalidText={gstinProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-line1"
|
||||||
|
labelText="Address line 1"
|
||||||
|
value={form.addressLine1}
|
||||||
|
onChange={(e) => set("addressLine1", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-line2"
|
||||||
|
labelText="Address line 2"
|
||||||
|
value={form.addressLine2}
|
||||||
|
onChange={(e) => set("addressLine2", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={5}>
|
||||||
|
<TextInput id="cm-city" labelText="City" value={form.city} onChange={(e) => set("city", e.target.value)} />
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={7}>
|
||||||
|
<Select
|
||||||
|
id="cm-state"
|
||||||
|
labelText="State (also the default place of supply)"
|
||||||
|
value={form.stateCode}
|
||||||
|
onChange={(e) => set("stateCode", e.target.value)}
|
||||||
|
>
|
||||||
|
<SelectItem value="" text="Not set" />
|
||||||
|
{INDIAN_STATES.map((s) => (
|
||||||
|
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={4}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-pin"
|
||||||
|
labelText="Pincode"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
value={form.pincode}
|
||||||
|
onChange={(e) => set("pincode", e.target.value)}
|
||||||
|
invalid={Boolean(pinProblem)}
|
||||||
|
invalidText={pinProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-po"
|
||||||
|
labelText="Default PO number (optional)"
|
||||||
|
value={form.poNumber}
|
||||||
|
onChange={(e) => set("poNumber", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-terms"
|
||||||
|
labelText="Payment terms (days, blank = default)"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.paymentTermsDays === null ? "" : String(form.paymentTermsDays)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.trim();
|
||||||
|
set("paymentTermsDays", raw === "" ? null : Number.isFinite(Number(raw)) ? Math.trunc(Number(raw)) : null);
|
||||||
|
}}
|
||||||
|
invalid={Boolean(termsProblem)}
|
||||||
|
invalidText={termsProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<TextArea
|
||||||
|
id="cm-notes"
|
||||||
|
labelText="Default notes / payment terms text"
|
||||||
|
helperText="Copied into the invoice notes when this client is picked and the notes are empty."
|
||||||
|
rows={3}
|
||||||
|
value={form.defaultNotes}
|
||||||
|
onChange={(e) => set("defaultNotes", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
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,
|
||||||
|
formatBytes,
|
||||||
|
formatWhen,
|
||||||
|
type BackupStatus,
|
||||||
|
type PendingRestore,
|
||||||
|
} from "../lib/backup";
|
||||||
|
import { clearVoicedLocalState, isResetConfirmed, RESET_WORD } from "../lib/reset";
|
||||||
|
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
const ZIP_FILTER = [{ name: "Voiced backup", extensions: ["zip"] }];
|
||||||
|
|
||||||
|
type RestoreStep = "confirm" | "staging" | "ready";
|
||||||
|
|
||||||
|
const joinPath = (dir: string, name: string) =>
|
||||||
|
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
const [restorePath, setRestorePath] = useState<string | null>(null);
|
||||||
|
const [step, setStep] = useState<RestoreStep>("confirm");
|
||||||
|
const [staged, setStaged] = useState<PendingRestore | null>(null);
|
||||||
|
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
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 {
|
||||||
|
setStatus(await api.getBackupStatus());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not load backup status", String(e));
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const backUpNow = async () => {
|
||||||
|
setBackingUp(true);
|
||||||
|
try {
|
||||||
|
let dir = "";
|
||||||
|
try {
|
||||||
|
dir = await api.getLastExportDir();
|
||||||
|
} catch {
|
||||||
|
// No remembered folder: the dialog opens wherever the system prefers.
|
||||||
|
}
|
||||||
|
const chosen = await save({ defaultPath: joinPath(dir, backupFileName()), filters: ZIP_FILTER });
|
||||||
|
if (!chosen) return;
|
||||||
|
const summary = await api.createBackup(ensureZipExtension(chosen));
|
||||||
|
toast.success("Backup saved", `${summary.fileCount} files, ${formatBytes(summary.totalBytes)}: ${summary.path}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Backup failed", String(e));
|
||||||
|
} finally {
|
||||||
|
setBackingUp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseRestoreFile = async () => {
|
||||||
|
try {
|
||||||
|
const picked = await open({ multiple: false, directory: false, filters: ZIP_FILTER });
|
||||||
|
if (typeof picked !== "string") return;
|
||||||
|
setRestoreError(null);
|
||||||
|
setStaged(null);
|
||||||
|
setStep("confirm");
|
||||||
|
setRestorePath(picked);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not open the file picker", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRestore = () => {
|
||||||
|
if (step === "staging") return;
|
||||||
|
setRestorePath(null);
|
||||||
|
void refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageRestore = async () => {
|
||||||
|
if (!restorePath) return;
|
||||||
|
setStep("staging");
|
||||||
|
setRestoreError(null);
|
||||||
|
try {
|
||||||
|
setStaged(await api.restoreBackup(restorePath));
|
||||||
|
setStep("ready");
|
||||||
|
} catch (e) {
|
||||||
|
setRestoreError(String(e));
|
||||||
|
setStep("confirm");
|
||||||
|
toast.error("This backup cannot be restored", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = async () => {
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
await api.restartApp();
|
||||||
|
} catch (e) {
|
||||||
|
setRestarting(false);
|
||||||
|
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the restore.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPending = async () => {
|
||||||
|
try {
|
||||||
|
await api.cancelPendingRestore();
|
||||||
|
toast.info("Restore cancelled", "Your data was not changed.");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not cancel the restore", String(e));
|
||||||
|
}
|
||||||
|
setRestorePath(null);
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAuto = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const saved = await api.setAutoBackup(enabled);
|
||||||
|
setStatus((prev) => (prev ? { ...prev, autoBackup: saved } : prev));
|
||||||
|
toast.success(saved ? "Automatic backup on" : "Automatic backup off");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not change automatic backup", String(e));
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFolder = async (path: string) => {
|
||||||
|
try {
|
||||||
|
await api.openFile(path);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not open the folder", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = status?.pendingRestore ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="voiced-stack">
|
||||||
|
{pending ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="warning"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="A restore is waiting for a restart"
|
||||||
|
subtitle={`${pending.backupName} (${pending.fileCount} files, ${formatBytes(pending.totalBytes)}) has been checked. Restart Voiced to switch to it; your current data is kept in a safety copy.`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{pending ? (
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" onClick={() => void restart()} disabled={restarting}>
|
||||||
|
{restarting ? "Restarting…" : "Restart now"}
|
||||||
|
</Button>
|
||||||
|
<Button size="md" kind="tertiary" onClick={() => void cancelPending()} disabled={restarting}>
|
||||||
|
Cancel restore
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Back up</h2>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Saves one zip with your invoices, clients, payments, settings, logo and signature images, archived PDFs and imported
|
||||||
|
fonts. Keep a copy somewhere other than this computer.
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" onClick={() => void backUpNow()} disabled={backingUp}>
|
||||||
|
{backingUp ? "Backing up…" : "Back up now"}
|
||||||
|
</Button>
|
||||||
|
{backingUp ? <InlineLoading description="Writing the backup" /> : null}
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Restore from backup</h2>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first,
|
||||||
|
never deleted.
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" kind="danger--tertiary" onClick={() => void chooseRestoreFile()} disabled={restorePath !== null || !!pending}>
|
||||||
|
Restore from backup…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{status?.lastRestore ? (
|
||||||
|
<p className="voiced-muted" role="status">
|
||||||
|
{status.lastRestore.ok
|
||||||
|
? `Last restore: ${status.lastRestore.backupName || "a backup"}, ${formatWhen(status.lastRestore.at)}.`
|
||||||
|
: `The last restore attempt (${formatWhen(status.lastRestore.at)}) did not go through: ${status.lastRestore.message}`}{" "}
|
||||||
|
{status.lastRestore.safetyDir ? (
|
||||||
|
<Button kind="ghost" size="sm" onClick={() => void api.revealInFolder(status.lastRestore!.safetyDir!).catch((e) => toast.error("Could not open the folder", String(e)))}>
|
||||||
|
Show safety copy
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Automatic backup</h2>
|
||||||
|
<Toggle
|
||||||
|
id="autoBackup"
|
||||||
|
labelText="Back up automatically every day"
|
||||||
|
labelA="Off"
|
||||||
|
labelB="On"
|
||||||
|
toggled={status?.autoBackup ?? true}
|
||||||
|
disabled={!status}
|
||||||
|
onToggle={(checked: boolean) => void toggleAuto(checked)}
|
||||||
|
/>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
While Voiced is open it writes one backup a day into its data folder and keeps the newest 14. This changes right away; you
|
||||||
|
do not need to click Save settings.
|
||||||
|
</p>
|
||||||
|
<p role="status">
|
||||||
|
{status === null
|
||||||
|
? "Loading…"
|
||||||
|
: status.lastAutoBackup
|
||||||
|
? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).`
|
||||||
|
: "No automatic backup yet."}
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" kind="tertiary" disabled={!status} onClick={() => status && void openFolder(status.autoDir)}>
|
||||||
|
Open backup folder
|
||||||
|
</Button>
|
||||||
|
</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’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"}
|
||||||
|
modalHeading={step === "ready" ? "Restart to finish restoring" : "Restore from this backup?"}
|
||||||
|
primaryButtonText={
|
||||||
|
step === "confirm" ? "Check and restore" : step === "staging" ? "Checking the backup…" : restarting ? "Restarting…" : "Restart now"
|
||||||
|
}
|
||||||
|
secondaryButtonText={step === "ready" ? "Later" : "Cancel"}
|
||||||
|
primaryButtonDisabled={step === "staging" || restarting}
|
||||||
|
onRequestClose={closeRestore}
|
||||||
|
onRequestSubmit={() => void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
|
||||||
|
onSecondarySubmit={closeRestore}
|
||||||
|
>
|
||||||
|
<div className="voiced-stack">
|
||||||
|
{restoreError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Cannot restore" subtitle={restoreError} /> : null}
|
||||||
|
{step === "ready" && staged ? (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<strong>{staged.backupName}</strong> passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
|
||||||
|
{formatWhen(staged.backupCreatedAt)}.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
|
||||||
|
when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
File: <strong>{restorePath}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
|
||||||
|
contents of this backup.
|
||||||
|
</p>
|
||||||
|
<ul className="voiced-list">
|
||||||
|
<li>The file is checked first; a damaged or newer-version backup is refused and nothing changes.</li>
|
||||||
|
<li>Your current data is moved to a safety copy in Voiced’s backups folder before the swap. It is never deleted.</li>
|
||||||
|
<li>Voiced restarts to complete the restore.</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ComboBox,
|
||||||
|
FileUploaderButton,
|
||||||
|
InlineLoading,
|
||||||
|
InlineNotification,
|
||||||
|
PasswordInput,
|
||||||
|
RadioButton,
|
||||||
|
RadioButtonGroup,
|
||||||
|
TextArea,
|
||||||
|
TextInput,
|
||||||
|
Tag,
|
||||||
|
Tile,
|
||||||
|
Toggle,
|
||||||
|
} from "@carbon/react";
|
||||||
|
import { Save } from "@carbon/icons-react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { ErpnextConfig, ErpnextConfigInput, ErpnextConnectionTest, ErpnextOptionItem, ErpnextOptions } from "../lib/erpnext";
|
||||||
|
import {
|
||||||
|
OPTION_LIST_LABELS,
|
||||||
|
UOM_UNITS,
|
||||||
|
connectionProblems,
|
||||||
|
emptyOptions,
|
||||||
|
hasProblems,
|
||||||
|
mirrorSupported,
|
||||||
|
optionErrors,
|
||||||
|
optionText,
|
||||||
|
sortWarnings,
|
||||||
|
toInput,
|
||||||
|
validateConfigForm,
|
||||||
|
versionsLine,
|
||||||
|
warningSeverity,
|
||||||
|
withStoredValue,
|
||||||
|
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;
|
||||||
|
|
||||||
|
interface PickProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** The loaded list; an empty list means "not loaded", and the field takes typed text instead. */
|
||||||
|
items: ErpnextOptionItem[];
|
||||||
|
/** Why this list is empty although the lists were loaded (a load error for this list). */
|
||||||
|
error?: string;
|
||||||
|
/** The control is switched off, with the reason shown under it. */
|
||||||
|
disabledReason?: string;
|
||||||
|
helper?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A searchable dropdown over a live list, or a text box while the list has not been loaded. */
|
||||||
|
function PickField({ id, label, value, onChange, items, error, disabledReason, helper }: PickProps) {
|
||||||
|
const options = useMemo(() => withStoredValue(items, value), [items, value]);
|
||||||
|
const note = disabledReason ?? error ?? helper;
|
||||||
|
if (items.length === 0 || disabledReason) {
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
id={id}
|
||||||
|
labelText={label}
|
||||||
|
value={value}
|
||||||
|
disabled={Boolean(disabledReason)}
|
||||||
|
invalid={Boolean(error) && !disabledReason}
|
||||||
|
invalidText={error}
|
||||||
|
helperText={error ? undefined : note}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ComboBox
|
||||||
|
id={id}
|
||||||
|
titleText={label}
|
||||||
|
items={options}
|
||||||
|
itemToString={(i: ErpnextOptionItem | null) => (i ? optionText(i) : "")}
|
||||||
|
selectedItem={options.find((o) => o.name === value) ?? null}
|
||||||
|
onChange={({ selectedItem }: { selectedItem?: ErpnextOptionItem | null }) => onChange(selectedItem?.name ?? "")}
|
||||||
|
shouldFilterItem={({ item, inputValue }: { item: ErpnextOptionItem; inputValue: string | null }) =>
|
||||||
|
!inputValue || optionText(item).toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
}
|
||||||
|
helperText={helper}
|
||||||
|
placeholder="Search or choose"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children, note }: { title: string; children: React.ReactNode; note?: string }) {
|
||||||
|
return (
|
||||||
|
<section className="voiced-erp__section" aria-label={title}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{note ? <p className="voiced-muted">{note}</p> : null}
|
||||||
|
<div className="voiced-erp__fields">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings, Integrations, ERPNext. It has its own Save: the page-level Save settings button does not cover it. */
|
||||||
|
export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [saved, setSaved] = useState<ErpnextConfig | null>(null);
|
||||||
|
const [form, setForm] = useState<ErpnextConfigInput | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [replacing, setReplacing] = useState(false);
|
||||||
|
const [test, setTest] = useState<ErpnextConnectionTest | null>(null);
|
||||||
|
/** A test result from this session that is not saved yet. */
|
||||||
|
const [testUnsaved, setTestUnsaved] = useState(false);
|
||||||
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [options, setOptions] = useState<ErpnextOptions | null>(null);
|
||||||
|
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showProblems, setShowProblems] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await api.erpnextGetConfig();
|
||||||
|
setSaved(cfg);
|
||||||
|
setForm(toInput(cfg));
|
||||||
|
setTest(cfg.lastDetectResult);
|
||||||
|
setTestUnsaved(false);
|
||||||
|
setLoadError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setLoadError(String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active && form === null) void load();
|
||||||
|
// Reload only on first show; edits must survive switching tabs.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return <InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the ERPNext settings" subtitle={loadError} />;
|
||||||
|
}
|
||||||
|
if (!form || !saved) return <InlineLoading description="Loading ERPNext settings" />;
|
||||||
|
|
||||||
|
const set = <K extends keyof ErpnextConfigInput>(key: K, value: ErpnextConfigInput[K]) => setForm((f) => (f ? { ...f, [key]: value } : f));
|
||||||
|
const secretStored = saved.apiSecretSet;
|
||||||
|
const secretShown = secretStored && !replacing && !form.clearSecret;
|
||||||
|
const problems: FormProblems = showProblems ? validateConfigForm(form, secretStored, test) : {};
|
||||||
|
const connProblems = connectionProblems(form, secretStored);
|
||||||
|
const canConnect = !hasProblems(connProblems);
|
||||||
|
const optErrors = optionErrors(options);
|
||||||
|
const opts = options ?? emptyOptions();
|
||||||
|
const noCompany = !form.company.trim();
|
||||||
|
const companyReason = noCompany ? "Pick a company first." : undefined;
|
||||||
|
const seriesMode = form.namingMode === "series";
|
||||||
|
const mirrorOk = mirrorSupported(test);
|
||||||
|
|
||||||
|
const loadOptions = async (values: ErpnextConfigInput) => {
|
||||||
|
setLoadingOptions(true);
|
||||||
|
try {
|
||||||
|
setOptions(await api.erpnextLoadOptions(values));
|
||||||
|
} catch (e) {
|
||||||
|
setOptions(null);
|
||||||
|
toast.error("Could not load the ERPNext lists", String(e));
|
||||||
|
} finally {
|
||||||
|
setLoadingOptions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTest = async () => {
|
||||||
|
setShowProblems(true);
|
||||||
|
if (!canConnect) return;
|
||||||
|
setTesting(true);
|
||||||
|
setTestError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.erpnextTestConnection(form);
|
||||||
|
setTest(result);
|
||||||
|
setTestUnsaved(true);
|
||||||
|
let next = form;
|
||||||
|
// A site that keeps no posted name cannot mirror the Voiced number.
|
||||||
|
if (!result.features.v2Naming && form.namingMode === "mirror") {
|
||||||
|
next = { ...form, namingMode: "series" };
|
||||||
|
setForm(next);
|
||||||
|
}
|
||||||
|
await loadOptions(next);
|
||||||
|
} catch (e) {
|
||||||
|
setTestError(String(e));
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeCompany = (company: string) => {
|
||||||
|
const next = { ...form, company };
|
||||||
|
setForm(next);
|
||||||
|
// Accounts, addresses and cost centers belong to the company, so the lists are reloaded for it.
|
||||||
|
if (options && canConnect) void loadOptions(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setShowProblems(true);
|
||||||
|
setSaveError(null);
|
||||||
|
if (hasProblems(validateConfigForm(form, secretStored, test))) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const cfg = await api.erpnextSaveConfig({ ...form, ...(testUnsaved && test ? { lastDetectResult: test } : {}) });
|
||||||
|
setSaved(cfg);
|
||||||
|
setForm(toInput(cfg));
|
||||||
|
setReplacing(false);
|
||||||
|
setTest(cfg.lastDetectResult ?? test);
|
||||||
|
setTestUnsaved(false);
|
||||||
|
setShowProblems(false);
|
||||||
|
toast.success("ERPNext settings saved");
|
||||||
|
} catch (e) {
|
||||||
|
const text = String(e);
|
||||||
|
setSaveError(text);
|
||||||
|
toast.error("Could not save the ERPNext settings", text);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPem = async (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
set("extraCaPem", (await file.text()).trim());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not read the certificate file", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const secretInvalid = problems.apiSecret ?? (saveError && /secret again/i.test(saveError) ? saveError : undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voiced-stack voiced-erp">
|
||||||
|
<div>
|
||||||
|
<h2 className="voiced-detail__h2">ERPNext</h2>
|
||||||
|
<p>
|
||||||
|
Send issued invoices and payments to your ERPNext site as Sales Invoices. Everything is sent from this computer; ERPNext is never
|
||||||
|
needed to issue an invoice.
|
||||||
|
</p>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Create a dedicated ERPNext user for Voiced with only these permissions: create and submit Sales Invoice, read on the master data
|
||||||
|
(Customer, Item, Address, Account, Company), and write on File. Use that user’s API key and secret below. An address must be
|
||||||
|
https://; plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test hosts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="Connection">
|
||||||
|
<TextInput
|
||||||
|
id="erp-baseUrl"
|
||||||
|
labelText="Site address"
|
||||||
|
placeholder="https://erp.example.com"
|
||||||
|
value={form.baseUrl}
|
||||||
|
invalid={Boolean(problems.baseUrl)}
|
||||||
|
invalidText={problems.baseUrl}
|
||||||
|
onChange={(e) => set("baseUrl", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
id="erp-apiKey"
|
||||||
|
labelText="API key"
|
||||||
|
autoComplete="off"
|
||||||
|
value={form.apiKey}
|
||||||
|
invalid={Boolean(problems.apiKey)}
|
||||||
|
invalidText={problems.apiKey}
|
||||||
|
onChange={(e) => set("apiKey", e.target.value)}
|
||||||
|
/>
|
||||||
|
{secretShown ? (
|
||||||
|
<div className="voiced-erp__secret">
|
||||||
|
<p className="voiced-erp__label" id="erp-secret-label">
|
||||||
|
API secret
|
||||||
|
</p>
|
||||||
|
<p aria-labelledby="erp-secret-label">
|
||||||
|
<Tag type="green" size="sm">
|
||||||
|
set
|
||||||
|
</Tag>{" "}
|
||||||
|
<span className="voiced-muted">The secret is stored on this computer and is never shown again.</span>
|
||||||
|
</p>
|
||||||
|
<div className="voiced-header-action">
|
||||||
|
<Button kind="tertiary" size="sm" onClick={() => setReplacing(true)}>
|
||||||
|
Replace
|
||||||
|
</Button>
|
||||||
|
<Button kind="ghost" size="sm" onClick={() => set("clearSecret", true)}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<PasswordInput
|
||||||
|
id="erp-apiSecret"
|
||||||
|
labelText="API secret"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.apiSecret}
|
||||||
|
invalid={Boolean(secretInvalid)}
|
||||||
|
invalidText={secretInvalid}
|
||||||
|
helperText={form.clearSecret ? "The stored secret is removed when you save, unless you type a new one." : undefined}
|
||||||
|
onChange={(e) => set("apiSecret", e.target.value)}
|
||||||
|
/>
|
||||||
|
{secretStored ? (
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setReplacing(false);
|
||||||
|
setForm((f) => (f ? { ...f, apiSecret: "", clearSecret: false } : f));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Keep the stored secret
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<TextArea
|
||||||
|
id="erp-extraCa"
|
||||||
|
labelText="Extra CA certificate (optional, PEM)"
|
||||||
|
helperText="Only for a self-hosted site whose certificate is not trusted by this computer."
|
||||||
|
rows={3}
|
||||||
|
value={form.extraCaPem}
|
||||||
|
onChange={(e) => set("extraCaPem", e.target.value)}
|
||||||
|
/>
|
||||||
|
<FileUploaderButton
|
||||||
|
labelText="Load from file…"
|
||||||
|
buttonKind="ghost"
|
||||||
|
size="sm"
|
||||||
|
accept={[".pem", ".crt", ".cer"]}
|
||||||
|
disableLabelChanges
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
void readPem(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="voiced-header-action">
|
||||||
|
<Button kind="secondary" size="md" onClick={() => void runTest()} disabled={testing || loadingOptions}>
|
||||||
|
{testing ? "Testing…" : "Test connection"}
|
||||||
|
</Button>
|
||||||
|
{testing || loadingOptions ? <InlineLoading description={testing ? "Contacting ERPNext" : "Loading lists"} /> : null}
|
||||||
|
{!canConnect && showProblems ? <span className="voiced-muted">Fix the address, key and secret first.</span> : null}
|
||||||
|
</div>
|
||||||
|
{testError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Connection failed" subtitle={testError} /> : null}
|
||||||
|
{test ? (
|
||||||
|
<Tile className="voiced-erp__result" aria-label="Connection test result">
|
||||||
|
<p>
|
||||||
|
<strong>Connected as {test.user || "an unknown user"}</strong>{" "}
|
||||||
|
<Tag type={test.indiaCompliance ? "green" : "warm-gray"} size="sm">
|
||||||
|
{test.indiaCompliance ? "India Compliance detected" : "India Compliance not detected"}
|
||||||
|
</Tag>
|
||||||
|
</p>
|
||||||
|
<p className="voiced-muted">{versionsLine(test)}</p>
|
||||||
|
{testUnsaved ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="info"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="Save to keep this result"
|
||||||
|
subtitle="Sending invoices needs a saved connection test."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="voiced-muted">Last saved test result.</p>
|
||||||
|
)}
|
||||||
|
{sortWarnings(test.warnings).map((w, i) => {
|
||||||
|
const severity = warningSeverity(w.code);
|
||||||
|
return (
|
||||||
|
<InlineNotification
|
||||||
|
key={`${w.code}-${i}`}
|
||||||
|
kind={severity}
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title={SEVERITY_TITLE[severity]}
|
||||||
|
subtitle={w.message}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{test.warnings.length === 0 ? <p className="voiced-muted">No problems found with the current settings.</p> : null}
|
||||||
|
<p className="voiced-muted">Test again after changing the mappings below to refresh these warnings.</p>
|
||||||
|
</Tile>
|
||||||
|
) : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Company and accounts"
|
||||||
|
note={options ? "Choose from what your ERPNext site has." : "Run Test connection to choose from your site's lists; until then, type the exact names."}
|
||||||
|
>
|
||||||
|
{options && options.errors.length > 0 ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="warning"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="Some lists could not be loaded"
|
||||||
|
subtitle={options.errors.map((e) => `${OPTION_LIST_LABELS[e.list] ?? e.list}: ${e.message}`).join(" ")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<PickField
|
||||||
|
id="erp-company"
|
||||||
|
label="Company"
|
||||||
|
value={form.company}
|
||||||
|
onChange={changeCompany}
|
||||||
|
items={opts.companies}
|
||||||
|
error={optErrors.get("companies")}
|
||||||
|
/>
|
||||||
|
<PickField
|
||||||
|
id="erp-companyAddress"
|
||||||
|
label="Company address"
|
||||||
|
value={form.companyAddress}
|
||||||
|
onChange={(v) => set("companyAddress", v)}
|
||||||
|
items={opts.companyAddresses}
|
||||||
|
error={optErrors.get("companyAddresses")}
|
||||||
|
disabledReason={companyReason}
|
||||||
|
helper="Required by India Compliance on every invoice."
|
||||||
|
/>
|
||||||
|
<PickField
|
||||||
|
id="erp-incomeAccount"
|
||||||
|
label="Income account"
|
||||||
|
value={form.incomeAccount}
|
||||||
|
onChange={(v) => set("incomeAccount", v)}
|
||||||
|
items={opts.incomeAccounts}
|
||||||
|
error={optErrors.get("incomeAccounts")}
|
||||||
|
disabledReason={companyReason}
|
||||||
|
/>
|
||||||
|
<PickField
|
||||||
|
id="erp-costCenter"
|
||||||
|
label="Cost center"
|
||||||
|
value={form.costCenter}
|
||||||
|
onChange={(v) => set("costCenter", v)}
|
||||||
|
items={opts.costCenters}
|
||||||
|
error={optErrors.get("costCenters")}
|
||||||
|
disabledReason={companyReason}
|
||||||
|
/>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["cgstAccount", "CGST account"],
|
||||||
|
["sgstAccount", "SGST account"],
|
||||||
|
["utgstAccount", "UTGST account"],
|
||||||
|
["igstAccount", "IGST account"],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<PickField
|
||||||
|
key={key}
|
||||||
|
id={`erp-${key}`}
|
||||||
|
label={label}
|
||||||
|
value={form[key]}
|
||||||
|
onChange={(v) => set(key, v)}
|
||||||
|
items={opts.taxAccounts}
|
||||||
|
error={optErrors.get("taxAccounts")}
|
||||||
|
disabledReason={companyReason}
|
||||||
|
helper={key === "utgstAccount" ? "Only needed when the supplier is in a union territory without a legislature." : "Must be the account set in GST Settings."}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<TextInput
|
||||||
|
id="erp-paymentBank"
|
||||||
|
labelText="Payment bank account"
|
||||||
|
helperText="The bank or cash account that receives payments, e.g. HDFC Bank - AC. Needed to send payments."
|
||||||
|
value={form.paymentBankAccount}
|
||||||
|
onChange={(e) => set("paymentBankAccount", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
id="erp-tdsAccount"
|
||||||
|
labelText="TDS account"
|
||||||
|
helperText="The account that holds tax deducted by clients. Needed only for payments with TDS."
|
||||||
|
value={form.tdsAccount}
|
||||||
|
onChange={(e) => set("tdsAccount", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Customers and items">
|
||||||
|
<TextInput
|
||||||
|
id="erp-defaultItem"
|
||||||
|
labelText="Default item code (optional)"
|
||||||
|
helperText="Used for invoice lines without an item code of their own; a line with none is sent as a description-only row."
|
||||||
|
value={form.defaultItemCode}
|
||||||
|
onChange={(e) => set("defaultItemCode", e.target.value)}
|
||||||
|
/>
|
||||||
|
<PickField
|
||||||
|
id="erp-customerGroup"
|
||||||
|
label="Customer group for new customers"
|
||||||
|
value={form.customerGroup}
|
||||||
|
onChange={(v) => set("customerGroup", v)}
|
||||||
|
items={opts.customerGroups}
|
||||||
|
error={optErrors.get("customerGroups")}
|
||||||
|
/>
|
||||||
|
<PickField
|
||||||
|
id="erp-territory"
|
||||||
|
label="Territory for new customers"
|
||||||
|
value={form.territory}
|
||||||
|
onChange={(v) => set("territory", v)}
|
||||||
|
items={opts.territories}
|
||||||
|
error={optErrors.get("territories")}
|
||||||
|
/>
|
||||||
|
<PickField
|
||||||
|
id="erp-priceList"
|
||||||
|
label="Selling price list"
|
||||||
|
value={form.sellingPriceList}
|
||||||
|
onChange={(v) => set("sellingPriceList", v)}
|
||||||
|
items={opts.priceLists}
|
||||||
|
error={optErrors.get("priceLists")}
|
||||||
|
/>
|
||||||
|
<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. 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 ${unitLabel(unit).toLowerCase()}`}
|
||||||
|
value={form.uomMap[unit] ?? ""}
|
||||||
|
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
|
||||||
|
items={opts.uoms}
|
||||||
|
error={optErrors.get("uoms")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Invoice numbers">
|
||||||
|
<RadioButtonGroup
|
||||||
|
legendText="Numbering in ERPNext"
|
||||||
|
name="erp-namingMode"
|
||||||
|
orientation="vertical"
|
||||||
|
valueSelected={form.namingMode}
|
||||||
|
onChange={(value) => set("namingMode", value === "series" ? "series" : "mirror")}
|
||||||
|
>
|
||||||
|
<RadioButton
|
||||||
|
id="erp-nm-mirror"
|
||||||
|
value="mirror"
|
||||||
|
labelText="Mirror the Voiced number (recommended)"
|
||||||
|
disabled={!mirrorOk}
|
||||||
|
/>
|
||||||
|
<RadioButton id="erp-nm-series" value="series" labelText="Use an ERPNext naming series" />
|
||||||
|
</RadioButtonGroup>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
{mirrorOk
|
||||||
|
? "Mirror keeps the number printed on the PDF as the ERPNext invoice name, so GST returns match. Needs Frappe 15.73 or later."
|
||||||
|
: "Mirror is unavailable: this ERPNext version replaces a posted invoice number. The Voiced number is stored in the remarks instead."}
|
||||||
|
</p>
|
||||||
|
{problems.namingMode ? <p className="voiced-erp__error">{problems.namingMode}</p> : null}
|
||||||
|
<PickField
|
||||||
|
id="erp-namingSeries"
|
||||||
|
label="Naming series"
|
||||||
|
value={form.namingSeries}
|
||||||
|
onChange={(v) => set("namingSeries", v)}
|
||||||
|
items={opts.namingSeries}
|
||||||
|
error={problems.namingSeries ?? optErrors.get("namingSeries")}
|
||||||
|
disabledReason={seriesMode ? undefined : "Only used with an ERPNext naming series."}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Sending">
|
||||||
|
<Toggle
|
||||||
|
id="erp-submit"
|
||||||
|
labelText="Submit invoices when sending"
|
||||||
|
labelA="Off: drafts for review"
|
||||||
|
labelB="On: submitted"
|
||||||
|
toggled={form.submitOnPush}
|
||||||
|
onToggle={(v) => set("submitOnPush", v)}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
id="erp-attach"
|
||||||
|
labelText="Attach the PDF"
|
||||||
|
labelA="Off"
|
||||||
|
labelB="On"
|
||||||
|
toggled={form.attachPdf}
|
||||||
|
onToggle={(v) => set("attachPdf", v)}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
id="erp-auto"
|
||||||
|
labelText="Send automatically when an invoice is issued"
|
||||||
|
labelA="Off"
|
||||||
|
labelB="On"
|
||||||
|
toggled={form.autoPushOnIssue}
|
||||||
|
onToggle={(v) => set("autoPushOnIssue", v)}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
id="erp-customers"
|
||||||
|
labelText="Create customers that are missing in ERPNext"
|
||||||
|
labelA="Off"
|
||||||
|
labelB="On"
|
||||||
|
toggled={form.createMissingCustomers}
|
||||||
|
onToggle={(v) => set("createMissingCustomers", v)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{saveError && !secretInvalid ? <InlineNotification kind="error" lowContrast hideCloseButton title="Not saved" subtitle={saveError} /> : null}
|
||||||
|
<div>
|
||||||
|
<Button renderIcon={Save} onClick={() => void save()} disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save ERPNext settings"}
|
||||||
|
</Button>
|
||||||
|
<p className="voiced-muted">This tab has its own Save button; Save settings at the bottom of the page does not cover it.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { api } from "../lib/api";
|
||||||
import type { InvoiceExport } from "../hooks/useInvoiceExport";
|
import type { InvoiceExport } from "../hooks/useInvoiceExport";
|
||||||
import FlattenProgressModal from "./FlattenProgressModal";
|
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}
|
{busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null}
|
||||||
|
|
||||||
{error && !flattenFailed ? (
|
{error && !flattenFailed ? (
|
||||||
<InlineNotification kind="error" lowContrast title="The PDF was not exported" subtitle={error.message} onCloseButtonClick={exp.dismiss}>
|
// Carbon throws if a notification has buttons inside it, and an uncaught throw blanks the whole window.
|
||||||
<div className="voiced-notice-actions">
|
// A single action goes through ActionableNotification's own button instead.
|
||||||
<Button kind="ghost" size="sm" onClick={exp.retry}>
|
<ActionableNotification
|
||||||
Retry
|
inline
|
||||||
</Button>
|
kind="error"
|
||||||
</div>
|
lowContrast
|
||||||
</InlineNotification>
|
title="The PDF was not exported"
|
||||||
|
subtitle={error.message}
|
||||||
|
actionButtonLabel="Retry"
|
||||||
|
onActionButtonClick={exp.retry}
|
||||||
|
onCloseButtonClick={exp.dismiss}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{result ? (
|
{result ? (
|
||||||
@@ -54,7 +59,8 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
|
|||||||
title={`Saved to ${result.path}`}
|
title={`Saved to ${result.path}`}
|
||||||
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
|
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
|
||||||
onCloseButtonClick={exp.dismiss}
|
onCloseButtonClick={exp.dismiss}
|
||||||
>
|
/>
|
||||||
|
{/* Beside the notification, not inside it: Carbon throws on interactive children. */}
|
||||||
<div className="voiced-notice-actions">
|
<div className="voiced-notice-actions">
|
||||||
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
||||||
Open file
|
Open file
|
||||||
@@ -63,7 +69,6 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
|
|||||||
Show in folder
|
Show in folder
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</InlineNotification>
|
|
||||||
{result.identicalToIssued === false ? (
|
{result.identicalToIssued === false ? (
|
||||||
<InlineNotification
|
<InlineNotification
|
||||||
kind="warning"
|
kind="warning"
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Column,
|
||||||
|
Grid,
|
||||||
|
InlineLoading,
|
||||||
|
Modal,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TextInput,
|
||||||
|
} from "@carbon/react";
|
||||||
|
import { Add, Edit, TrashCan } from "@carbon/icons-react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
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";
|
||||||
|
|
||||||
|
/** Saved line items: description, HSN/SAC, type, unit and amount (kept as integer paise). */
|
||||||
|
export default function ItemPresetsPanel({ active }: { active: boolean }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState<ItemPreset | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<ItemPreset | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
const launcher = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setPresets(await api.listItemPresets());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not load item presets", String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) void load();
|
||||||
|
}, [active, load]);
|
||||||
|
|
||||||
|
const openEditor = (preset: ItemPreset, trigger: HTMLElement) => {
|
||||||
|
launcher.current = trigger;
|
||||||
|
setTouched(false);
|
||||||
|
setEditing(preset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = <K extends keyof ItemPreset>(key: K, value: ItemPreset[K]) =>
|
||||||
|
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
setTouched(true);
|
||||||
|
if (!editing.description.trim()) return;
|
||||||
|
if (editing.mode === "rate" && !isValidUnit(editing.unit)) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
// 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();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not save preset", String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!deleting || deleting.id === null) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.deleteItemPreset(deleting.id);
|
||||||
|
toast.success("Preset deleted", deleting.description);
|
||||||
|
setDeleting(null);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not delete preset", String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <InlineLoading description="Loading item presets" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voiced-stack">
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<p className="voiced-muted">Saved line items you can add to an invoice in one click.</p>
|
||||||
|
<Button
|
||||||
|
kind="tertiary"
|
||||||
|
size="sm"
|
||||||
|
renderIcon={Add}
|
||||||
|
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(blankPreset(), e.currentTarget)}
|
||||||
|
>
|
||||||
|
Add preset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{presets.length === 0 ? (
|
||||||
|
<div className="voiced-empty">No item presets yet.</div>
|
||||||
|
) : (
|
||||||
|
<Table aria-label="Item presets">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>Description</TableHeader>
|
||||||
|
<TableHeader>HSN/SAC</TableHeader>
|
||||||
|
<TableHeader>Type</TableHeader>
|
||||||
|
<TableHeader>Amount</TableHeader>
|
||||||
|
<TableHeader>Actions</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<TableRow key={p.id ?? p.description}>
|
||||||
|
<TableCell>{p.description}</TableCell>
|
||||||
|
<TableCell>{p.hsnSac || "—"}</TableCell>
|
||||||
|
<TableCell>{p.mode === "rate" ? ratePerText(p.unit) : "Fixed amount"}</TableCell>
|
||||||
|
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="voiced-header-action">
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
renderIcon={Edit}
|
||||||
|
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor({ ...p }, e.currentTarget)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
renderIcon={TrashCan}
|
||||||
|
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
launcher.current = e.currentTarget;
|
||||||
|
setDeleting(p);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={editing !== null}
|
||||||
|
modalHeading={editing?.id ? "Edit item preset" : "New item preset"}
|
||||||
|
primaryButtonText={busy ? "Saving…" : "Save preset"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={busy}
|
||||||
|
onRequestClose={() => setEditing(null)}
|
||||||
|
onRequestSubmit={() => void save()}
|
||||||
|
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{editing ? (
|
||||||
|
<Grid narrow>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<TextInput
|
||||||
|
id="ip-desc"
|
||||||
|
labelText="Description"
|
||||||
|
value={editing.description}
|
||||||
|
onChange={(e) => set("description", e.target.value)}
|
||||||
|
invalid={touched && !editing.description.trim()}
|
||||||
|
invalidText="Description is required"
|
||||||
|
data-modal-primary-focus
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="ip-hsn"
|
||||||
|
labelText="HSN/SAC (optional)"
|
||||||
|
value={editing.hsnSac}
|
||||||
|
onChange={(e) => set("hsnSac", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<Select
|
||||||
|
id="ip-mode"
|
||||||
|
labelText="Type"
|
||||||
|
value={editing.mode}
|
||||||
|
onChange={(e) => set("mode", e.target.value as LineMode)}
|
||||||
|
>
|
||||||
|
<SelectItem value="fixed" text="Fixed amount" />
|
||||||
|
<SelectItem value="rate" text="Rate × quantity" />
|
||||||
|
</Select>
|
||||||
|
</Column>
|
||||||
|
{editing.mode === "rate" ? (
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<UnitPicker id="ip-unit" value={editing.unit} onChange={(unit) => set("unit", unit)} />
|
||||||
|
</Column>
|
||||||
|
) : null}
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<NumberInput
|
||||||
|
id="ip-rate"
|
||||||
|
label={editing.mode === "rate" ? "Rate (₹)" : "Amount (₹)"}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={paiseToRupees(editing.ratePaise)}
|
||||||
|
onChange={(_e, { value }) => set("ratePaise", Math.max(0, rupeesToPaise(Number(value))))}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
danger
|
||||||
|
open={deleting !== null}
|
||||||
|
modalHeading="Delete item preset"
|
||||||
|
primaryButtonText={busy ? "Deleting…" : "Delete"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={busy}
|
||||||
|
onRequestClose={() => setDeleting(null)}
|
||||||
|
onRequestSubmit={() => void remove()}
|
||||||
|
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Delete the preset “{deleting?.description}”? Invoices already created from it are not affected.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { InlineNotification, Modal, Select, SelectItem, TextInput } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { todayIst } from "../lib/fiscal";
|
||||||
|
import { formatPaise, paiseToInput, parseRupeesToPaise } from "../lib/money";
|
||||||
|
import { PAYMENT_MODES, type Payment, type PaymentMode } from "../lib/payments";
|
||||||
|
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
invoiceId: number;
|
||||||
|
invoiceNumber: string;
|
||||||
|
/** What is still owed, in paise. */
|
||||||
|
balancePaise: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onRecorded: (payment: Payment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Record payment": cash received plus TDS deducted, validated against the balance before it is sent. */
|
||||||
|
export default function RecordPaymentModal({ open, invoiceId, invoiceNumber, balancePaise, onClose, onRecorded }: Props) {
|
||||||
|
const [paidOn, setPaidOn] = useState(todayIst());
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [tds, setTds] = useState("");
|
||||||
|
const [mode, setMode] = useState<PaymentMode>("bank_transfer");
|
||||||
|
const [reference, setReference] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
useReturnFocus(open);
|
||||||
|
|
||||||
|
// Each opening starts from the balance, so the common case (paid in full) is one click.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setPaidOn(todayIst());
|
||||||
|
setAmount(paiseToInput(balancePaise));
|
||||||
|
setTds("");
|
||||||
|
setMode("bank_transfer");
|
||||||
|
setReference("");
|
||||||
|
setNotes("");
|
||||||
|
setError(null);
|
||||||
|
setBusy(false);
|
||||||
|
}, [open, balancePaise]);
|
||||||
|
|
||||||
|
const amountPaise = parseRupeesToPaise(amount);
|
||||||
|
const tdsPaise = parseRupeesToPaise(tds);
|
||||||
|
const amountInvalid = amountPaise === null;
|
||||||
|
const tdsInvalid = tdsPaise === null;
|
||||||
|
const total = (amountPaise ?? 0) + (tdsPaise ?? 0);
|
||||||
|
const overBalance = !amountInvalid && !tdsInvalid && total > balancePaise;
|
||||||
|
const empty = !amountInvalid && !tdsInvalid && total === 0;
|
||||||
|
const dateInvalid = !/^\d{4}-\d{2}-\d{2}$/.test(paidOn);
|
||||||
|
const canSubmit = !busy && !amountInvalid && !tdsInvalid && !overBalance && !empty && !dateInvalid;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!canSubmit || amountPaise === null || tdsPaise === null) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payment = await api.recordPayment({ invoiceId, paidOn, amountPaise, tdsPaise, mode, reference, notes });
|
||||||
|
onRecorded(payment);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
modalHeading={`Record payment for ${invoiceNumber}`}
|
||||||
|
primaryButtonText={busy ? "Saving…" : "Record payment"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={!canSubmit}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onRequestSubmit={() => void submit()}
|
||||||
|
>
|
||||||
|
<div className="voiced-stack">
|
||||||
|
<p>
|
||||||
|
Balance due: <strong>{formatPaise(balancePaise)}</strong>
|
||||||
|
</p>
|
||||||
|
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Could not record the payment" subtitle={error} /> : null}
|
||||||
|
<TextInput
|
||||||
|
id="payDate"
|
||||||
|
type="date"
|
||||||
|
labelText="Date received"
|
||||||
|
value={paidOn}
|
||||||
|
invalid={dateInvalid}
|
||||||
|
invalidText="Enter a date"
|
||||||
|
onChange={(e) => setPaidOn(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
id="payAmount"
|
||||||
|
inputMode="decimal"
|
||||||
|
labelText="Amount received (₹)"
|
||||||
|
helperText="Cash that reached your account"
|
||||||
|
value={amount}
|
||||||
|
invalid={amountInvalid || overBalance || empty}
|
||||||
|
invalidText={
|
||||||
|
amountInvalid
|
||||||
|
? "Enter an amount like 1250.50"
|
||||||
|
: overBalance
|
||||||
|
? `Amount plus TDS is more than the balance of ${formatPaise(balancePaise)}`
|
||||||
|
: "Enter an amount received or TDS deducted"
|
||||||
|
}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
id="payTds"
|
||||||
|
inputMode="decimal"
|
||||||
|
labelText="TDS deducted by client (₹)"
|
||||||
|
helperText="Counts towards settling the invoice; leave empty if none"
|
||||||
|
value={tds}
|
||||||
|
invalid={tdsInvalid}
|
||||||
|
invalidText="Enter an amount like 125.00"
|
||||||
|
onChange={(e) => setTds(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select id="payMode" labelText="Mode" value={mode} onChange={(e) => setMode(e.target.value as PaymentMode)}>
|
||||||
|
{PAYMENT_MODES.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id} text={m.label} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<TextInput id="payRef" labelText="UTR / cheque number (optional)" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||||
|
<TextInput id="payNotes" labelText="Notes (optional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, InlineLoading, Modal, RadioButton, RadioButtonGroup, Tag } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { ErpnextPushResult } from "../lib/erpnext";
|
||||||
|
import { describePush, failedPush, summarizeBatch } from "../lib/erpnextUi";
|
||||||
|
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||||
|
|
||||||
|
export interface SendTarget {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
invoices: SendTarget[];
|
||||||
|
/** Cancelled or draft invoices that were left out of the selection. */
|
||||||
|
skipped?: number;
|
||||||
|
/** The "submit on push" setting; the user still chooses explicitly. */
|
||||||
|
defaultSubmit: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Called once when a send has finished, so the list can refresh its statuses. */
|
||||||
|
onFinished: (results: ErpnextPushResult[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = "confirm" | "running" | "done";
|
||||||
|
|
||||||
|
const TAG = { success: "green", info: "blue", warning: "warm-gray", error: "red" } as const;
|
||||||
|
|
||||||
|
/** Confirm step (draft or submit), then a sequential send with progress, then per-invoice results. A failure never stops the rest. */
|
||||||
|
export default function SendToErpnextModal({ open, invoices, skipped = 0, defaultSubmit, onClose, onFinished }: Props) {
|
||||||
|
const [submit, setSubmit] = useState(defaultSubmit);
|
||||||
|
const [phase, setPhase] = useState<Phase>("confirm");
|
||||||
|
const [results, setResults] = useState<ErpnextPushResult[]>([]);
|
||||||
|
const alive = useRef(true);
|
||||||
|
useReturnFocus(open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true;
|
||||||
|
return () => {
|
||||||
|
alive.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setSubmit(defaultSubmit);
|
||||||
|
setPhase("confirm");
|
||||||
|
setResults([]);
|
||||||
|
}, [open, defaultSubmit]);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setPhase("running");
|
||||||
|
const out: ErpnextPushResult[] = [];
|
||||||
|
for (const inv of invoices) {
|
||||||
|
let result: ErpnextPushResult;
|
||||||
|
try {
|
||||||
|
result = await api.erpnextPushInvoice(inv.id, submit);
|
||||||
|
} catch (e) {
|
||||||
|
// Not set up, or the command itself failed: this row fails, the others still go.
|
||||||
|
result = failedPush(inv.id, inv.number, e);
|
||||||
|
}
|
||||||
|
out.push(result);
|
||||||
|
if (alive.current) setResults([...out]);
|
||||||
|
}
|
||||||
|
if (alive.current) setPhase("done");
|
||||||
|
onFinished(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
const count = invoices.length;
|
||||||
|
const noun = count === 1 ? "invoice" : "invoices";
|
||||||
|
|
||||||
|
if (phase === "confirm") {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
modalHeading={count === 1 ? `Send ${invoices[0]?.number} to ERPNext` : `Send ${count} invoices to ERPNext`}
|
||||||
|
primaryButtonText={`Send ${count} ${noun}`}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={count === 0}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onRequestSubmit={() => void run()}
|
||||||
|
>
|
||||||
|
<div className="voiced-stack">
|
||||||
|
<RadioButtonGroup legendText="What should ERPNext do with it?" name="erp-send-mode" orientation="vertical" valueSelected={submit ? "submit" : "draft"} onChange={(v) => setSubmit(v === "submit")}>
|
||||||
|
<RadioButton id="erp-send-draft" value="draft" labelText="Save as a draft for review" />
|
||||||
|
<RadioButton id="erp-send-submit" value="submit" labelText="Submit it (posts to the books)" />
|
||||||
|
</RadioButtonGroup>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
An invoice that is already in ERPNext is never overwritten or created twice.
|
||||||
|
{skipped > 0 ? ` ${skipped} cancelled ${skipped === 1 ? "invoice is" : "invoices are"} left out: cancelled invoices are never sent.` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = phase === "done" ? summarizeBatch(results) : null;
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
passiveModal
|
||||||
|
modalHeading={phase === "running" ? "Sending to ERPNext" : "Send finished"}
|
||||||
|
preventCloseOnClickOutside
|
||||||
|
onRequestClose={() => {
|
||||||
|
if (phase === "done") onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="voiced-stack">
|
||||||
|
{phase === "running" ? (
|
||||||
|
<InlineLoading description={`Sent ${results.length} of ${count}…`} />
|
||||||
|
) : (
|
||||||
|
<p role="status">
|
||||||
|
<strong>{summary?.title}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="voiced-erp__results" aria-label="Results">
|
||||||
|
{results.map((r) => {
|
||||||
|
const note = describePush(r);
|
||||||
|
return (
|
||||||
|
<li key={r.invoiceId}>
|
||||||
|
<Tag type={TAG[note.kind]} size="sm">
|
||||||
|
{r.ok ? (r.noOp ? "Up to date" : r.remoteDocstatus === 1 ? "Submitted" : "Draft") : r.status === "conflict" ? "Conflict" : r.status === "refused" ? "Not sent" : "Error"}
|
||||||
|
</Tag>{" "}
|
||||||
|
<strong>{r.number || `#${r.invoiceId}`}</strong>
|
||||||
|
{r.remoteName && r.ok ? <span className="voiced-muted"> {r.remoteName}</span> : null}
|
||||||
|
{!r.ok && r.error ? <div className="voiced-muted">{r.error}</div> : null}
|
||||||
|
{r.ok && r.warnings.length > 0 ? <div className="voiced-muted">{r.warnings.join(" ")}</div> : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{phase === "done" ? (
|
||||||
|
<Button kind="primary" size="md" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createContext, useContext, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
|
||||||
|
import { ToastNotification } from "@carbon/react";
|
||||||
|
import { createToastStore, type ToastStore } from "../lib/toastStore";
|
||||||
|
|
||||||
|
export interface ToastApi {
|
||||||
|
success: (title: string, subtitle?: string) => number;
|
||||||
|
info: (title: string, subtitle?: string) => number;
|
||||||
|
warning: (title: string, subtitle?: string) => number;
|
||||||
|
/** Errors stay until the user closes them. */
|
||||||
|
error: (title: string, subtitle?: string) => number;
|
||||||
|
dismiss: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastApi | null>(null);
|
||||||
|
|
||||||
|
function apiFor(store: ToastStore): ToastApi {
|
||||||
|
return {
|
||||||
|
success: (t, s) => store.add("success", t, s),
|
||||||
|
info: (t, s) => store.add("info", t, s),
|
||||||
|
warning: (t, s) => store.add("warning", t, s),
|
||||||
|
error: (t, s) => store.add("error", t, s),
|
||||||
|
dismiss: store.dismiss,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stacked top-right toasts. Place it inside the Carbon <Theme> so the tokens apply. */
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const storeRef = useRef<ToastStore | null>(null);
|
||||||
|
if (!storeRef.current) storeRef.current = createToastStore();
|
||||||
|
const store = storeRef.current;
|
||||||
|
const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||||
|
const api = useMemo(() => apiFor(store), [store]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={api}>
|
||||||
|
{children}
|
||||||
|
{/* The live region stays mounted so screen readers announce toasts as they are added. */}
|
||||||
|
<div className="voiced-toasts" aria-live="polite" aria-relevant="additions" role="region" aria-label="Notifications">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<ToastNotification
|
||||||
|
key={t.id}
|
||||||
|
kind={t.kind}
|
||||||
|
lowContrast
|
||||||
|
title={t.title}
|
||||||
|
subtitle={t.subtitle}
|
||||||
|
role={t.kind === "error" ? "alert" : "status"}
|
||||||
|
onClose={() => {
|
||||||
|
store.dismiss(t.id);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
onCloseButtonClick={() => store.dismiss(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast(): ToastApi {
|
||||||
|
const api = useContext(ToastContext);
|
||||||
|
if (!api) throw new Error("useToast must be used inside <ToastProvider>");
|
||||||
|
return api;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { exportInvoicesV1, type InvoiceExportFormat } from "../lib/invoiceExportV1";
|
||||||
|
import { useToast } from "../components/ToastProvider";
|
||||||
|
|
||||||
|
/** "Export as voiced.invoice.v1": JSON or line-item CSV of the given invoices, through the save dialog. */
|
||||||
|
export function useGenericExport(): { busy: boolean; run: (ids: number[], format: InvoiceExportFormat) => Promise<void> } {
|
||||||
|
const toast = useToast();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const run = useCallback(
|
||||||
|
async (ids: number[], format: InvoiceExportFormat) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const done = await exportInvoicesV1(
|
||||||
|
{
|
||||||
|
getInvoice: api.getInvoice,
|
||||||
|
listPayments: api.listPayments,
|
||||||
|
listClients: api.listClients,
|
||||||
|
getLastExportDir: api.getLastExportDir,
|
||||||
|
save: ({ defaultPath, format: fmt }) =>
|
||||||
|
save({ defaultPath, filters: [fmt === "csv" ? { name: "CSV (line items)", extensions: ["csv"] } : { name: "JSON (voiced.invoice.v1)", extensions: ["json"] }] }),
|
||||||
|
writeExportFile: api.writeExportFile,
|
||||||
|
now: () => new Date(),
|
||||||
|
},
|
||||||
|
ids,
|
||||||
|
format,
|
||||||
|
);
|
||||||
|
if (done) toast.success(`Exported ${done.count} ${done.count === 1 ? "invoice" : "invoices"}`, done.path);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not export the invoices", String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
return { busy, run };
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives focus back to whatever had it when a modal opened, once the modal closes. Carbon's Modal only
|
||||||
|
* does this with a launcher ref, which does not suit modals opened from table rows or menus.
|
||||||
|
*/
|
||||||
|
export function useReturnFocus(open: boolean): void {
|
||||||
|
const origin = useRef<HTMLElement | null>(null);
|
||||||
|
const wasOpen = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !wasOpen.current) {
|
||||||
|
origin.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
} else if (!open && wasOpen.current) {
|
||||||
|
const el = origin.current;
|
||||||
|
origin.current = null;
|
||||||
|
// After the modal has unmounted its focus trap.
|
||||||
|
if (el && el.isConnected) setTimeout(() => el.focus(), 0);
|
||||||
|
}
|
||||||
|
wasOpen.current = open;
|
||||||
|
}, [open]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"schema": "voiced.invoice.v1",
|
||||||
|
"number": "INV/2026-001",
|
||||||
|
"issue_date": "2026-04-01",
|
||||||
|
"due_date": "2026-05-01",
|
||||||
|
"document_type": "tax_invoice",
|
||||||
|
"status": "issued",
|
||||||
|
"cancelled": false,
|
||||||
|
"cancelled_at": null,
|
||||||
|
"cancel_reason": null,
|
||||||
|
"currency": "INR",
|
||||||
|
"po_number": "PO-9",
|
||||||
|
"supplier": {
|
||||||
|
"name": "Test Vendor",
|
||||||
|
"address": "1 Main Rd\nPune",
|
||||||
|
"gstin": "27AAPFU0939F1ZV",
|
||||||
|
"pan": "AAPFU0939F",
|
||||||
|
"gst_registration": "regular",
|
||||||
|
"state": {
|
||||||
|
"code": "27",
|
||||||
|
"name": "Maharashtra"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"name": "Client Ltd",
|
||||||
|
"gstin": "29AABCU9603R1ZJ",
|
||||||
|
"gst_category": "registered_regular",
|
||||||
|
"address": {
|
||||||
|
"text": "12 MG Road\nBengaluru 560001",
|
||||||
|
"line1": "12 MG Road",
|
||||||
|
"line2": null,
|
||||||
|
"city": "Bengaluru",
|
||||||
|
"pincode": "560001",
|
||||||
|
"state": {
|
||||||
|
"code": "29",
|
||||||
|
"name": "Karnataka"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"place_of_supply": {
|
||||||
|
"code": "29",
|
||||||
|
"name": "Karnataka"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"line": 1,
|
||||||
|
"description": "Design",
|
||||||
|
"hsn_sac": "998391",
|
||||||
|
"quantity": "1",
|
||||||
|
"unit": null,
|
||||||
|
"rate_paise": 500010,
|
||||||
|
"rate": "5000.10",
|
||||||
|
"taxable_value_paise": 500010,
|
||||||
|
"taxable_value": "5000.10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line": 2,
|
||||||
|
"description": "Consulting",
|
||||||
|
"hsn_sac": null,
|
||||||
|
"quantity": "2",
|
||||||
|
"unit": "hour",
|
||||||
|
"rate_paise": 115505,
|
||||||
|
"rate": "1155.05",
|
||||||
|
"taxable_value_paise": 231000,
|
||||||
|
"taxable_value": "2310.00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tax": {
|
||||||
|
"cgst": {
|
||||||
|
"rate": "0",
|
||||||
|
"amount_paise": 0,
|
||||||
|
"amount": "0.00"
|
||||||
|
},
|
||||||
|
"sgst": {
|
||||||
|
"rate": "0",
|
||||||
|
"amount_paise": 0,
|
||||||
|
"amount": "0.00"
|
||||||
|
},
|
||||||
|
"utgst": {
|
||||||
|
"rate": "0",
|
||||||
|
"amount_paise": 0,
|
||||||
|
"amount": "0.00"
|
||||||
|
},
|
||||||
|
"igst": {
|
||||||
|
"rate": "18",
|
||||||
|
"amount_paise": 131400,
|
||||||
|
"amount": "1314.00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"totals": {
|
||||||
|
"subtotal_paise": 731010,
|
||||||
|
"subtotal": "7310.10",
|
||||||
|
"discount_paise": 1010,
|
||||||
|
"discount": "10.10",
|
||||||
|
"taxable_value_paise": 730000,
|
||||||
|
"taxable_value": "7300.00",
|
||||||
|
"tax_paise": 131400,
|
||||||
|
"tax": "1314.00",
|
||||||
|
"total_paise": 861400,
|
||||||
|
"total": "8614.00",
|
||||||
|
"amount_in_words": "Rupees Eight Thousand Six Hundred Fourteen Only"
|
||||||
|
},
|
||||||
|
"reverse_charge": false,
|
||||||
|
"notes": "Thank you",
|
||||||
|
"payments": [
|
||||||
|
{
|
||||||
|
"date": "2026-04-10",
|
||||||
|
"amount_paise": 700000,
|
||||||
|
"amount": "7000.00",
|
||||||
|
"tds_paise": 70000,
|
||||||
|
"tds": "700.00",
|
||||||
|
"mode": "bank_transfer",
|
||||||
|
"reference": "UTR123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -7,9 +7,22 @@ import type {
|
|||||||
InvoiceInput,
|
InvoiceInput,
|
||||||
InvoiceSeries,
|
InvoiceSeries,
|
||||||
InvoiceSummary,
|
InvoiceSummary,
|
||||||
|
ItemPreset,
|
||||||
Settings,
|
Settings,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { LogoAsset } from "./logo";
|
import type { LogoAsset } from "./logo";
|
||||||
|
import type { Payment, PaymentInput } from "./payments";
|
||||||
|
import type { BackupStatus, BackupSummary, PendingRestore } from "./backup";
|
||||||
|
import type { LedgerRow } from "./historyExport";
|
||||||
|
import type {
|
||||||
|
ErpnextConfig,
|
||||||
|
ErpnextConfigInput,
|
||||||
|
ErpnextConnectionTest,
|
||||||
|
ErpnextOptions,
|
||||||
|
ErpnextPaymentPushResult,
|
||||||
|
ErpnextPushResult,
|
||||||
|
ErpnextSyncStatus,
|
||||||
|
} from "./erpnext";
|
||||||
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
||||||
|
|
||||||
export interface ArchiveStatus {
|
export interface ArchiveStatus {
|
||||||
@@ -36,14 +49,23 @@ export const api = {
|
|||||||
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
|
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
|
||||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||||
|
|
||||||
|
listItemPresets: () => invoke<ItemPreset[]>("list_item_presets"),
|
||||||
|
saveItemPreset: (preset: ItemPreset) => invoke<ItemPreset>("save_item_preset", { preset }),
|
||||||
|
deleteItemPreset: (id: number) => invoke<void>("delete_item_preset", { id }),
|
||||||
|
|
||||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||||
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
||||||
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
||||||
cancelInvoice: (id: number, reason: string) =>
|
cancelInvoice: (id: number, reason: string) =>
|
||||||
invoke<Invoice>("cancel_invoice", { id, reason }),
|
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||||
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
||||||
|
listInvoiceLedger: () => invoke<LedgerRow[]>("list_invoice_ledger"),
|
||||||
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
||||||
|
|
||||||
|
recordPayment: (input: PaymentInput) => invoke<Payment>("record_payment", { input }),
|
||||||
|
listPayments: (invoiceId: number) => invoke<Payment[]>("list_payments", { invoiceId }),
|
||||||
|
deletePayment: (id: number) => invoke<void>("delete_payment", { id }),
|
||||||
|
|
||||||
saveDraft: (id: number | null, payload: unknown) =>
|
saveDraft: (id: number | null, payload: unknown) =>
|
||||||
invoke<number>("save_draft", { id, payload }),
|
invoke<number>("save_draft", { id, payload }),
|
||||||
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
|
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
|
||||||
@@ -92,4 +114,34 @@ export const api = {
|
|||||||
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||||
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
||||||
openFile: (path: string) => invoke<void>("open_file", { path }),
|
openFile: (path: string) => invoke<void>("open_file", { path }),
|
||||||
|
|
||||||
|
// Backup and restore. A restore is staged now and applied by the next start (see commands/backup.rs).
|
||||||
|
createBackup: (dest: string) => invoke<BackupSummary>("create_backup", { dest }),
|
||||||
|
restoreBackup: (path: string) => invoke<PendingRestore>("restore_backup", { path }),
|
||||||
|
cancelPendingRestore: () => invoke<void>("cancel_pending_restore"),
|
||||||
|
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"),
|
||||||
|
erpnextSaveConfig: (config: ErpnextConfigInput) => invoke<ErpnextConfig>("erpnext_save_config", { config }),
|
||||||
|
erpnextTestConnection: (config: ErpnextConfigInput) =>
|
||||||
|
invoke<ErpnextConnectionTest>("erpnext_test_connection", { config }),
|
||||||
|
erpnextLoadOptions: (config: ErpnextConfigInput) => invoke<ErpnextOptions>("erpnext_load_options", { config }),
|
||||||
|
/** `submit` omitted follows the "submit on push" setting. A failed push resolves with `ok: false`. */
|
||||||
|
erpnextPushInvoice: (id: number, submit?: boolean) =>
|
||||||
|
invoke<ErpnextPushResult>("erpnext_push_invoice", { id, submit: submit ?? null }),
|
||||||
|
/** One result per distinct id, in order; a failing row never stops the rest. */
|
||||||
|
erpnextPushInvoices: (ids: number[], submit?: boolean) =>
|
||||||
|
invoke<ErpnextPushResult[]>("erpnext_push_invoices", { ids, submit: submit ?? null }),
|
||||||
|
erpnextPushPayment: (paymentId: number) =>
|
||||||
|
invoke<ErpnextPaymentPushResult>("erpnext_push_payment", { paymentId }),
|
||||||
|
erpnextSyncStatus: (id: number) => invoke<ErpnextSyncStatus>("erpnext_sync_status", { id }),
|
||||||
|
/** Every invoice that has a sync row (invoices never pushed are absent). */
|
||||||
|
erpnextSyncStatuses: () => invoke<ErpnextSyncStatus[]>("erpnext_sync_statuses"),
|
||||||
|
/** `<base>/app/sales-invoice/<name>`; rejects when the invoice was never sent. */
|
||||||
|
erpnextOpenUrl: (id: number) => invoke<string>("erpnext_open_url", { id }),
|
||||||
};
|
};
|
||||||
|
|||||||