Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,202 @@
|
|||||||
# 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.1.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.
|
||||||
|
- **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.1.0`).
|
||||||
Windows host.
|
|
||||||
|
- **Windows 10/11:** run `Voiced_1.1.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.1.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.
|
||||||
|
|
||||||
|
## 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 +205,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 +218,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.
|
||||||
@@ -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}'.
|
||||||
|
|||||||
@@ -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.1.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.1.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.1.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.1.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,14 @@
|
|||||||
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 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,79 @@ 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 restore staged by the Data tab is swapped in now, before anything opens the database.
|
||||||
|
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
|
||||||
|
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 +121,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 +165,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 +177,16 @@ 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::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 +207,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.1.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,313 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button, InlineLoading, InlineNotification, Modal, 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 { 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 and re-running setup. */
|
||||||
|
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);
|
||||||
|
useReturnFocus(restorePath !== null);
|
||||||
|
|
||||||
|
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 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>
|
||||||
|
|
||||||
|
<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,32 @@ 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"),
|
||||||
|
|
||||||
|
// 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 }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { backupFileName, ensureZipExtension, formatBytes, formatWhen } from "./backup";
|
||||||
|
|
||||||
|
describe("backup helpers", () => {
|
||||||
|
it("names backups by local date and time", () => {
|
||||||
|
expect(backupFileName(new Date(2026, 2, 4, 9, 5))).toBe("voiced-backup-20260304-0905.zip");
|
||||||
|
});
|
||||||
|
it("adds the zip extension only when missing", () => {
|
||||||
|
expect(ensureZipExtension("/a/b")).toBe("/a/b.zip");
|
||||||
|
expect(ensureZipExtension("/a/b.ZIP")).toBe("/a/b.ZIP");
|
||||||
|
expect(ensureZipExtension("/a/b.zip")).toBe("/a/b.zip");
|
||||||
|
});
|
||||||
|
it("formats sizes", () => {
|
||||||
|
expect(formatBytes(512)).toBe("512 B");
|
||||||
|
expect(formatBytes(1536)).toBe("1.5 KB");
|
||||||
|
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
|
||||||
|
expect(formatBytes(300 * 1024 * 1024)).toBe("300 MB");
|
||||||
|
});
|
||||||
|
it("falls back to the raw text for an unparseable time", () => {
|
||||||
|
expect(formatWhen("not a date")).toBe("not a date");
|
||||||
|
expect(formatWhen("2026-03-04T10:00:00Z")).toMatch(/2026/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/** Shapes returned by the backup commands (src-tauri/src/commands/backup.rs). */
|
||||||
|
export interface BackupSummary {
|
||||||
|
path: string;
|
||||||
|
createdAt: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
fileCount: number;
|
||||||
|
totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingRestore {
|
||||||
|
backupName: string;
|
||||||
|
backupCreatedAt: string;
|
||||||
|
appVersion: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
fileCount: number;
|
||||||
|
totalBytes: number;
|
||||||
|
stagedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastRestore {
|
||||||
|
ok: boolean;
|
||||||
|
at: string;
|
||||||
|
backupName: string;
|
||||||
|
message: string;
|
||||||
|
safetyDir: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupStatus {
|
||||||
|
autoBackup: boolean;
|
||||||
|
autoDir: string;
|
||||||
|
lastAutoBackup: string | null;
|
||||||
|
autoBackupCount: number;
|
||||||
|
pendingRestore: PendingRestore | null;
|
||||||
|
lastRestore: LastRestore | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
/** `voiced-backup-20260304-1530.zip` in local time. */
|
||||||
|
export function backupFileName(now: Date = new Date()): string {
|
||||||
|
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
||||||
|
return `voiced-backup-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The save dialog does not always add the extension (notably on Linux). */
|
||||||
|
export function ensureZipExtension(path: string): string {
|
||||||
|
return /\.zip$/i.test(path) ? path : `${path}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1536 -> "1.5 KB". Sizes of backup contents are display-only, so plain division is fine. */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let i = 0;
|
||||||
|
while (value >= 1024 && i < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "4 Mar 2026, 3:30 pm" for an ISO timestamp; the raw text if it does not parse. */
|
||||||
|
export function formatWhen(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { composeAddress, pincodeError, splitLegacyAddress } from "./clientAddress";
|
||||||
|
|
||||||
|
describe("composeAddress", () => {
|
||||||
|
it("joins lines and the city/state/pincode line", () => {
|
||||||
|
expect(
|
||||||
|
composeAddress({ line1: "12 MG Road", line2: "Floor 2", city: "Bengaluru", stateCode: "29", pincode: "560001" }),
|
||||||
|
).toBe("12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
||||||
|
});
|
||||||
|
it("skips empty parts", () => {
|
||||||
|
expect(composeAddress({ line1: " A ", line2: "", city: "", stateCode: "", pincode: "560001" })).toBe("A\n560001");
|
||||||
|
expect(composeAddress({ line1: "", line2: "", city: "Pune", stateCode: "", pincode: "" })).toBe("Pune");
|
||||||
|
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "29", pincode: "" })).toBe("Karnataka");
|
||||||
|
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "", pincode: "" })).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitLegacyAddress", () => {
|
||||||
|
it("puts the first line in line1 and the rest in line2", () => {
|
||||||
|
expect(splitLegacyAddress("Old style\nfree text\r\nBangalore")).toEqual({
|
||||||
|
line1: "Old style",
|
||||||
|
line2: "free text, Bangalore",
|
||||||
|
});
|
||||||
|
expect(splitLegacyAddress("")).toEqual({ line1: "", line2: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pincodeError", () => {
|
||||||
|
it("accepts empty and 6 digits only", () => {
|
||||||
|
expect(pincodeError("")).toBeUndefined();
|
||||||
|
expect(pincodeError("560001")).toBeUndefined();
|
||||||
|
expect(pincodeError("56001")).toBeDefined();
|
||||||
|
expect(pincodeError("56000A")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||