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 |
@@ -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,6 +1,6 @@
|
|||||||
# Voiced
|
# Voiced
|
||||||
|
|
||||||
An offline-first GST invoicing desktop app for **Test Vendor**, built by **Bongbetic**.
|
An offline-first GST invoicing desktop app built by **Bongbetic**.
|
||||||
|
|
||||||
Voiced turns a few details into a clean, print-ready PDF invoice. It keeps a running invoice-number series in a
|
Voiced turns a few details into a clean, print-ready PDF invoice. It keeps a running invoice-number series in a
|
||||||
local database, remembers your vendor, client and bank details, and works without an account or a network
|
local database, remembers your vendor, client and bank details, and works without an account or a network
|
||||||
@@ -8,16 +8,23 @@ connection. The only optional network feature is the ERPNext push, which you con
|
|||||||
|
|
||||||
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
|
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
|
||||||
|
|
||||||
|
- **First-run setup.** A four-step wizard (business, branding, bank, numbering) starts blank: no vendor, bank or tax
|
||||||
|
details are pre-filled, and a business name and state are required. **Settings, Data, Run setup again** reopens it
|
||||||
|
with your current details; invoices, clients and backups are kept, and numbering carries on unless you change the
|
||||||
|
series prefix or digits.
|
||||||
- **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series,
|
- **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series,
|
||||||
drafts, issue and cancel. An issued invoice is immutable: it stores a snapshot of the vendor, client, GST figures
|
drafts, issue and cancel. An issued invoice is immutable: it stores a snapshot of the vendor, client, GST figures
|
||||||
and layout. Line items are a fixed amount or rate x quantity (per second, minute, hour, session or unit), with
|
and layout. Line items are a fixed amount or rate x quantity in a unit of measure (time, distance, area, weight,
|
||||||
item presets.
|
volume, a count, or a custom unit; a time typed as `4:30` counts as 4.5 hours), with item presets.
|
||||||
- **GST.** All money and tax maths is done in integer paise. CGST+SGST, IGST or UTGST follow from the supplier's
|
- **GST.** All money and tax maths is done in integer paise. CGST+SGST, IGST or UTGST follow from the supplier's
|
||||||
state and the place of supply, and a mismatch is rejected. An unregistered vendor gets a plain "Invoice" with no
|
state and the place of supply, and a mismatch is rejected. An unregistered vendor gets a plain "Invoice" with no
|
||||||
tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words.
|
tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words.
|
||||||
- **Templates.** 14 templates: Voiced Standard (Classic) and 13 designs measured from reference originals (Linea,
|
- **Templates.** 14 templates: Voiced Standard (Classic) and 13 further designs (Linea,
|
||||||
Monogram, Serenity, Crimson Grid, Tangerine Ledger, Slate Band, Teal Swoosh, Purple Pop, Citrus Split, Monolith,
|
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
|
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/`.
|
logo position. Specs are in `docs/templates/`.
|
||||||
@@ -66,14 +73,28 @@ npm run app:dev # tauri dev, served from localhost:1420 (does not exercis
|
|||||||
| `npm run build` | `tsc --noEmit` and `vite build` (the type-check gate) |
|
| `npm 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 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 fonts:verify` | Checks the bundled fonts against `public/fonts/manifest.json` (`fonts:manifest` regenerates it) |
|
||||||
| `npm run logo:measure` | Regenerates `src/pdf/templates/slots.generated.ts` from the reference PDFs; must be idempotent |
|
| `npm run 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` |
|
| `npm run thumbnails` | Regenerates `public/templates/*.png` from the synthetic sample invoice (neutral vendor and bank, generated wordmark) |
|
||||||
| `npm run templates:compare` | reference vs Voiced side-by-side PNGs under `/tmp` |
|
| `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 |
|
| `cargo test --manifest-path src-tauri/Cargo.toml` | Rust unit tests |
|
||||||
| `npm run app:build` | Production build for the current platform (custom-protocol) |
|
| `npm run app:build` | Production build for the current platform (custom-protocol) |
|
||||||
|
|
||||||
The release procedure, including the manual first-run checklist, is in `docs/RELEASE.md`.
|
The release procedure, including the manual first-run checklist, is in `docs/RELEASE.md`.
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
|
||||||
|
`v1.1.0`).
|
||||||
|
|
||||||
|
- **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
|
## Where data lives
|
||||||
|
|
||||||
The Tauri identifier is `com.bongbetic.voiced`. Voiced uses two directories, which differ only on Windows:
|
The Tauri identifier is `com.bongbetic.voiced`. Voiced uses two directories, which differ only on Windows:
|
||||||
@@ -184,7 +205,8 @@ Hosts without an `nsis` package (for example Void Linux):
|
|||||||
# from https://github.com/tauri-apps/nsis-tauri-utils/releases
|
# from https://github.com/tauri-apps/nsis-tauri-utils/releases
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer has not been run on a real Windows machine.
|
The installer has been installed and run once in a Windows 11 virtual machine; it has not been tried on physical
|
||||||
|
hardware.
|
||||||
|
|
||||||
## Installing on Void Linux
|
## Installing on Void Linux
|
||||||
|
|
||||||
@@ -215,7 +237,7 @@ cd void-packages && ./xbps-src pkg voiced
|
|||||||
```
|
```
|
||||||
|
|
||||||
`INSTALL` and `REMOVE` must sit next to the template; xbps-src packs them. The source is a tagged archive of the
|
`INSTALL` and `REMOVE` must sit next to the template; xbps-src packs them. The source is a tagged archive of the
|
||||||
private repository (see the comments at the top of the template; `checksum=SKIP` until a tarball is published).
|
repository (see the comments at the top of the template; `checksum=SKIP` until a tarball is published).
|
||||||
xbps-src builds in a network-less chroot, so vendor the dependencies first: run `cargo vendor` in `src-tauri/` (with a
|
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
|
`.cargo/config.toml` pointing at `vendor/`) and populate the npm cache, and include the results in the source
|
||||||
archive. The template runs `npm ci` (not `--omit=optional`: that removes
|
archive. The template runs `npm ci` (not `--omit=optional`: that removes
|
||||||
@@ -241,6 +263,7 @@ src/ React + Carbon frontend
|
|||||||
fonts/ Font registration, roles, glyph priming
|
fonts/ Font registration, roles, glyph priming
|
||||||
render/ renderCore, Web Worker, latest-wins client
|
render/ renderCore, Web Worker, latest-wins client
|
||||||
flatten.ts assemble.ts Rasterise at 300 DPI and rebuild an image-only PDF
|
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
|
src-tauri/src/ Rust backend
|
||||||
db.rs Schema as numbered migrations (copies the DB aside before migrating)
|
db.rs Schema as numbered migrations (copies the DB aside before migrating)
|
||||||
gst.rs Integer-paise money and GST maths
|
gst.rs Integer-paise money and GST maths
|
||||||
@@ -250,7 +273,7 @@ src-tauri/src/ Rust backend
|
|||||||
integrations/erpnext/ ERPNext client, mapping, discovery, push
|
integrations/erpnext/ ERPNext client, mapping, discovery, push
|
||||||
selftest.rs Unattended real-webview self-test
|
selftest.rs Unattended real-webview self-test
|
||||||
public/fonts/ Bundled TTF families, OFL texts, manifest.json (plus legacy IBM Plex web fonts)
|
public/fonts/ Bundled TTF families, OFL texts, manifest.json (plus legacy IBM Plex web fonts)
|
||||||
public/templates/ Template thumbnails
|
public/templates/ Template thumbnails (rendered from synthetic sample data)
|
||||||
docs/ Template specs, voiced.invoice.v1, release checklist
|
docs/ Template specs, voiced.invoice.v1, release checklist
|
||||||
scripts/ Font, logo, template, thumbnail and ERPNext end-to-end tooling
|
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
|
packaging/void/ xbps-src template, build-xbps.sh, desktop entry, INSTALL and REMOVE scripts
|
||||||
@@ -272,15 +295,20 @@ Settings, Diagnostics.
|
|||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Windows and WebView2 have never been run on a real machine; the NSIS installer is cross-compiled only.
|
- Windows and WebView2 have only been exercised in a Windows 11 virtual machine, not on physical hardware; the NSIS
|
||||||
- The 13 reference-based templates are re-creations of reference designs. the reference design's content licence may restrict this, so
|
installer is cross-compiled and unsigned.
|
||||||
ship them only in this vendor's build until it is confirmed.
|
|
||||||
- The SAC code for the vendor's services and the signature rule still need confirmation from a chartered accountant.
|
- 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.
|
- 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.
|
- 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
|
- Not implemented (deferred): copies and watermarks, PDF/A, custom margins, user background textures, SVG logo
|
||||||
import, CESS, composition and SEZ invoices.
|
import, CESS, composition and SEZ invoices.
|
||||||
|
|
||||||
## Ownership
|
## Licence and credit
|
||||||
|
|
||||||
© Bongbetic. All rights reserved. This is a private application.
|
Voiced is released under the [Apache License 2.0](LICENSE). Copyright 2026 Bongbetic.
|
||||||
|
|
||||||
|
You are free to use, modify, fork and redistribute it. If you do, you must keep the `LICENSE` and `NOTICE` files and
|
||||||
|
the copyright notices, and state your changes; please also credit the original: **Based on Voiced by Bongbetic**.
|
||||||
|
Bundled fonts keep their own SIL Open Font License texts (see "Fonts and licences").
|
||||||
|
|
||||||
|
Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md). Contributions are licensed under the same terms.
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ Linux and Windows. Windows has never been run on real hardware, so treat it as u
|
|||||||
- **Template picker.** Open all 14 templates; switch A4 and Letter and the margin presets; check the logo slots.
|
- **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"
|
- **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.
|
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
|
- **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.
|
a template that uses it changes, remove it again.
|
||||||
- **Clients.** Create a client with structured address and GST category; use it on an invoice.
|
- **Clients.** Create a client with structured address and GST category; use it on an invoice.
|
||||||
@@ -83,9 +88,6 @@ Linux and Windows. Windows has never been run on real hardware, so treat it as u
|
|||||||
- **Integrations.** Test connection and push an invoice to a test ERPNext (README, "ERPNext integration").
|
- **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.
|
- **Single instance.** Launch a second copy: the first window must come to the front.
|
||||||
|
|
||||||
## 6. Reference licence
|
## 6. Open items
|
||||||
|
|
||||||
The 13 reference-based templates are re-creations of reference designs. the reference design's content licence may restrict that. Until it
|
A chartered accountant's sign-off on the SAC code and the signature rule is still open.
|
||||||
is confirmed in writing, distribute builds containing those templates only to this vendor. If the answer is no,
|
|
||||||
remove the templates from `src/pdf/templates/registry.ts` and `catalog.ts` (Classic stays) and rebuild. Also still
|
|
||||||
open: a chartered accountant's sign-off on the SAC code and the signature rule.
|
|
||||||
|
|||||||
@@ -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}'.
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ Notes on the fields:
|
|||||||
|
|
||||||
- **Items.** `taxable_value` of a line is the line amount before the invoice-level discount. `totals.taxable_value` is
|
- **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`.
|
`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
|
- **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.
|
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
|
- **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured
|
||||||
|
|||||||
@@ -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": "1.0.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"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
# pure Rust and need no extra system libraries.
|
# pure Rust and need no extra system libraries.
|
||||||
#
|
#
|
||||||
pkgname=voiced
|
pkgname=voiced
|
||||||
version=1.0.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"
|
||||||
@@ -36,7 +36,7 @@ makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel"
|
|||||||
depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
|
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
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4647,7 +4647,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voiced"
|
name = "voiced"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voiced"
|
name = "voiced"
|
||||||
version = "1.0.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"
|
||||||
|
|||||||
@@ -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!(
|
||||||
.unwrap_err()
|
archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
||||||
.contains("not a PDF"));
|
.unwrap_err()
|
||||||
|
.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(
|
||||||
.unwrap();
|
"UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1",
|
||||||
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed"));
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
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]
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ use crate::AppState;
|
|||||||
use rusqlite::{params, Connection, Row};
|
use rusqlite::{params, Connection, Row};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"];
|
const CATEGORIES: &[&str] = &[
|
||||||
|
"registered_regular",
|
||||||
|
"unregistered",
|
||||||
|
"composition",
|
||||||
|
"sez",
|
||||||
|
"overseas",
|
||||||
|
];
|
||||||
|
|
||||||
const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
|
const CLIENT_COLS: &str =
|
||||||
|
"c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
|
||||||
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
|
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
|
||||||
c.payment_terms_days,
|
c.payment_terms_days,
|
||||||
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
|
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
|
||||||
@@ -33,7 +40,13 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
|||||||
|
|
||||||
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
|
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
|
||||||
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
|
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
|
||||||
pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String {
|
pub fn compose_address(
|
||||||
|
line1: &str,
|
||||||
|
line2: &str,
|
||||||
|
city: &str,
|
||||||
|
state_code: &str,
|
||||||
|
pincode: &str,
|
||||||
|
) -> String {
|
||||||
let state_name = gst::STATES
|
let state_name = gst::STATES
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(c, _)| *c == state_code)
|
.find(|(c, _)| *c == state_code)
|
||||||
@@ -246,7 +259,10 @@ mod tests {
|
|||||||
c.po_number = "PO-9".into();
|
c.po_number = "PO-9".into();
|
||||||
let saved = save_client_impl(&conn, c).unwrap();
|
let saved = save_client_impl(&conn, c).unwrap();
|
||||||
assert!(saved.id.is_some());
|
assert!(saved.id.is_some());
|
||||||
assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
assert_eq!(
|
||||||
|
saved.address,
|
||||||
|
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
|
||||||
|
);
|
||||||
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
|
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
|
||||||
let listed = list_clients_impl(&conn).unwrap();
|
let listed = list_clients_impl(&conn).unwrap();
|
||||||
assert_eq!(listed.len(), 1);
|
assert_eq!(listed.len(), 1);
|
||||||
@@ -282,7 +298,10 @@ mod tests {
|
|||||||
edit.name = "B".into();
|
edit.name = "B".into();
|
||||||
edit.gst_category = "sez".into();
|
edit.gst_category = "sez".into();
|
||||||
let again = save_client_impl(&conn, edit).unwrap();
|
let again = save_client_impl(&conn, edit).unwrap();
|
||||||
assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez"));
|
assert_eq!(
|
||||||
|
(again.name.as_str(), again.gst_category.as_str()),
|
||||||
|
("B", "sez")
|
||||||
|
);
|
||||||
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||||
let mut ghost = blank("Ghost");
|
let mut ghost = blank("Ghost");
|
||||||
ghost.id = Some(999);
|
ghost.id = Some(999);
|
||||||
@@ -292,7 +311,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validation_rejects_bad_input() {
|
fn validation_rejects_bad_input() {
|
||||||
let conn = open_in_memory().unwrap();
|
let conn = open_in_memory().unwrap();
|
||||||
assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name"));
|
assert!(save_client_impl(&conn, blank(" "))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("name"));
|
||||||
|
|
||||||
let mut c = blank("X");
|
let mut c = blank("X");
|
||||||
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
|
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
|
||||||
@@ -342,9 +363,11 @@ mod tests {
|
|||||||
delete_client_impl(&conn, id).unwrap();
|
delete_client_impl(&conn, id).unwrap();
|
||||||
assert!(list_clients_impl(&conn).unwrap().is_empty());
|
assert!(list_clients_impl(&conn).unwrap().is_empty());
|
||||||
let (name, addr): (String, String) = conn
|
let (name, addr): (String, String) = conn
|
||||||
.query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
"SELECT client_name, client_address FROM invoices WHERE number='T-1'",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
|
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,12 @@
|
|||||||
//! never held across a network call.
|
//! never held across a network call.
|
||||||
|
|
||||||
use crate::integrations::erpnext::client::ErpClient;
|
use crate::integrations::erpnext::client::ErpClient;
|
||||||
use crate::integrations::erpnext::config::{self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView};
|
use crate::integrations::erpnext::config::{
|
||||||
use crate::integrations::erpnext::discovery::{self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts};
|
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::erpnext::push::{self, PaymentPushResult, PushResult};
|
||||||
use crate::integrations::SyncStatus;
|
use crate::integrations::SyncStatus;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@@ -18,12 +22,18 @@ pub fn get_config_impl(conn: &Connection) -> Result<ErpnextConfigView, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Merges the form with the stored row (blank secret keeps the stored one) without saving.
|
/// Merges the form with the stored row (blank secret keeps the stored one) without saving.
|
||||||
pub fn resolve_config(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfig, String> {
|
pub fn resolve_config(
|
||||||
|
conn: &Connection,
|
||||||
|
input: ErpnextConfigInput,
|
||||||
|
) -> Result<ErpnextConfig, String> {
|
||||||
let stored = config::load(conn)?;
|
let stored = config::load(conn)?;
|
||||||
input.resolve(&stored)
|
input.resolve(&stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfigView, String> {
|
pub fn save_config_impl(
|
||||||
|
conn: &Connection,
|
||||||
|
input: ErpnextConfigInput,
|
||||||
|
) -> Result<ErpnextConfigView, String> {
|
||||||
let cfg = resolve_config(conn, input)?;
|
let cfg = resolve_config(conn, input)?;
|
||||||
config::save(conn, &cfg)?;
|
config::save(conn, &cfg)?;
|
||||||
Ok(cfg.view())
|
Ok(cfg.view())
|
||||||
@@ -32,7 +42,11 @@ pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result<
|
|||||||
/// What the connection test needs from the local database.
|
/// What the connection test needs from the local database.
|
||||||
pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
|
pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
|
||||||
let registration: String = conn
|
let registration: String = conn
|
||||||
.query_row("SELECT gst_registration FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT gst_registration FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT number FROM invoices ORDER BY id")
|
.prepare("SELECT number FROM invoices ORDER BY id")
|
||||||
@@ -54,7 +68,11 @@ pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
|
|||||||
)
|
)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next));
|
.map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next));
|
||||||
Ok(LocalFacts { vendor_registered: registration != "unregistered", invalid_numbers, next_number })
|
Ok(LocalFacts {
|
||||||
|
vendor_registered: registration != "unregistered",
|
||||||
|
invalid_numbers,
|
||||||
|
next_number,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -65,7 +83,10 @@ pub fn erpnext_get_config(state: State<AppState>) -> Result<ErpnextConfigView, S
|
|||||||
|
|
||||||
/// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it.
|
/// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn erpnext_save_config(state: State<AppState>, config: ErpnextConfigInput) -> Result<ErpnextConfigView, String> {
|
pub fn erpnext_save_config(
|
||||||
|
state: State<AppState>,
|
||||||
|
config: ErpnextConfigInput,
|
||||||
|
) -> Result<ErpnextConfigView, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
save_config_impl(&conn, config)
|
save_config_impl(&conn, config)
|
||||||
}
|
}
|
||||||
@@ -105,7 +126,10 @@ fn saved_client(state: &AppState) -> Result<ErpClient, String> {
|
|||||||
config::load(&conn)?
|
config::load(&conn)?
|
||||||
};
|
};
|
||||||
if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() {
|
if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() {
|
||||||
return Err("ERPNext is not set up yet: enter the address, API key and API secret in Settings.".into());
|
return Err(
|
||||||
|
"ERPNext is not set up yet: enter the address, API key and API secret in Settings."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(ErpClient::from_config(&cfg)?)
|
Ok(ErpClient::from_config(&cfg)?)
|
||||||
}
|
}
|
||||||
@@ -134,7 +158,10 @@ pub async fn erpnext_push_invoices(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn erpnext_push_payment(state: State<'_, AppState>, payment_id: i64) -> Result<PaymentPushResult, String> {
|
pub async fn erpnext_push_payment(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
payment_id: i64,
|
||||||
|
) -> Result<PaymentPushResult, String> {
|
||||||
let http = saved_client(&state)?;
|
let http = saved_client(&state)?;
|
||||||
Ok(push::push_payment(&state.db, &http, payment_id).await)
|
Ok(push::push_payment(&state.db, &http, payment_id).await)
|
||||||
}
|
}
|
||||||
@@ -165,15 +192,22 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
|
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
|
||||||
let mut base = json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" });
|
let mut base =
|
||||||
base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
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()
|
serde_json::from_value(base).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn saved_config_is_returned_without_the_secret() {
|
fn saved_config_is_returned_without_the_secret() {
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
let view = save_config_impl(&conn, input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true }))).unwrap();
|
let view = save_config_impl(
|
||||||
|
&conn,
|
||||||
|
input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true })),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(view.api_secret_set);
|
assert!(view.api_secret_set);
|
||||||
let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap();
|
let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap();
|
||||||
assert!(!shown.contains("super-secret-value"));
|
assert!(!shown.contains("super-secret-value"));
|
||||||
@@ -190,7 +224,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_values_are_resolved_without_saving() {
|
fn test_values_are_resolved_without_saving() {
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
let resolved = resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap();
|
let resolved =
|
||||||
|
resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap();
|
||||||
assert_eq!(resolved.api_secret.expose(), "typed-not-saved");
|
assert_eq!(resolved.api_secret.expose(), "typed-not-saved");
|
||||||
assert!(!get_config_impl(&conn).unwrap().api_secret_set);
|
assert!(!get_config_impl(&conn).unwrap().api_secret_set);
|
||||||
}
|
}
|
||||||
@@ -203,7 +238,8 @@ mod tests {
|
|||||||
assert!(facts.invalid_numbers.is_empty());
|
assert!(facts.invalid_numbers.is_empty());
|
||||||
assert!(facts.next_number.is_some());
|
assert!(facts.next_number.is_some());
|
||||||
|
|
||||||
conn.execute("UPDATE app_settings SET gst_registration = 'regular'", []).unwrap();
|
conn.execute("UPDATE app_settings SET gst_registration = 'regular'", [])
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO invoices (number, invoice_date, created_at, updated_at)
|
"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')",
|
VALUES ('INV/2026-001', '2026-04-01', 'n', 'n'), ('INVOICE/2026/000001', '2026-04-01', 'n', 'n')",
|
||||||
|
|||||||
@@ -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,8 +42,12 @@ 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(
|
||||||
.map_err(|e| e.to_string())
|
"SELECT last_export_dir FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_existing(path: &str) -> Result<PathBuf, String> {
|
fn require_existing(path: &str) -> Result<PathBuf, String> {
|
||||||
@@ -71,7 +79,11 @@ fn reveal_fallback_command(path: &Path) -> Command {
|
|||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
#[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,8 +162,11 @@ 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(
|
||||||
.to_string()
|
&path.to_string_lossy(),
|
||||||
|
percent_encoding::NON_ALPHANUMERIC,
|
||||||
|
)
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -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(
|
||||||
.optional()
|
&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"),
|
||||||
.map_err(|e| e.to_string())
|
params![id],
|
||||||
|
map_row,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_user_fonts_impl(conn: &Connection, include_hidden: bool) -> Result<Vec<UserFont>, String> {
|
pub fn list_user_fonts_impl(
|
||||||
|
conn: &Connection,
|
||||||
|
include_hidden: bool,
|
||||||
|
) -> Result<Vec<UserFont>, String> {
|
||||||
let sql = format!(
|
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!(
|
||||||
.unwrap_err()
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", false))
|
||||||
.contains("licence"));
|
.unwrap_err()
|
||||||
assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Montserrat", true))
|
.contains("licence")
|
||||||
.unwrap_err()
|
);
|
||||||
.contains("Now, Gotham, Open Sauce One"));
|
assert!(import_font_impl(
|
||||||
assert!(!dir.path().join("fonts").exists(), "a refused import writes nothing");
|
&mut conn,
|
||||||
|
dir.path(),
|
||||||
|
®ular(),
|
||||||
|
&request("Montserrat", true)
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Now, Gotham, Open Sauce One"));
|
||||||
|
assert!(
|
||||||
|
!dir.path().join("fonts").exists(),
|
||||||
|
"a refused import writes nothing"
|
||||||
|
);
|
||||||
let mut woff2 = regular();
|
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,12 +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 super::payments::{derive_summary, summary_for, today_ist};
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
|
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
|
||||||
|
Settings,
|
||||||
};
|
};
|
||||||
|
use crate::units;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use rusqlite::{named_params, params, Connection, OptionalExtension, Row};
|
use rusqlite::{named_params, params, Connection, OptionalExtension, Row};
|
||||||
@@ -130,6 +132,15 @@ fn parse_date(label: &str, value: &str) -> Result<(), String> {
|
|||||||
/// overflow integer paise arithmetic.
|
/// 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());
|
||||||
@@ -140,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"));
|
||||||
@@ -271,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!(
|
||||||
@@ -296,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 {}",
|
||||||
@@ -309,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());
|
||||||
}
|
}
|
||||||
@@ -330,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())?;
|
||||||
@@ -427,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,
|
||||||
@@ -445,8 +470,11 @@ 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(
|
||||||
.map_err(db)?;
|
"DELETE FROM invoice_drafts WHERE id = ?1",
|
||||||
|
params![draft_id],
|
||||||
|
)
|
||||||
|
.map_err(db)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().map_err(db)?;
|
tx.commit().map_err(db)?;
|
||||||
@@ -464,9 +492,17 @@ pub fn issue_invoice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
/// 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
|
let payments: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM payments WHERE invoice_id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if payments > 0 {
|
if payments > 0 {
|
||||||
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
|
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
|
||||||
@@ -482,7 +518,11 @@ pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Resu
|
|||||||
.map_err(|e| e.to_string())?;
|
.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 {
|
||||||
@@ -525,7 +565,14 @@ pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, Stri
|
|||||||
let status: String = row.get(6)?;
|
let status: String = row.get(6)?;
|
||||||
let due_date: String = row.get(3)?;
|
let due_date: String = row.get(3)?;
|
||||||
let total_paise = gst::rupees_to_paise(total);
|
let total_paise = gst::rupees_to_paise(total);
|
||||||
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today);
|
let payment = derive_summary(
|
||||||
|
&status,
|
||||||
|
total_paise,
|
||||||
|
row.get(9)?,
|
||||||
|
row.get(10)?,
|
||||||
|
&due_date,
|
||||||
|
&today,
|
||||||
|
);
|
||||||
Ok(InvoiceSummary {
|
Ok(InvoiceSummary {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
number: row.get(1)?,
|
number: row.get(1)?,
|
||||||
@@ -566,7 +613,8 @@ pub struct InvoiceLedgerRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||||
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
|
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
{
|
{
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
||||||
@@ -666,11 +714,17 @@ pub fn save_draft_impl(
|
|||||||
|
|
||||||
pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> {
|
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<_>>>()
|
||||||
@@ -682,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()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -691,7 +748,11 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
|
|||||||
|
|
||||||
pub fn get_draft_impl(conn: &Connection, id: i64) -> Result<serde_json::Value, String> {
|
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())?;
|
||||||
@@ -765,19 +826,28 @@ 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(
|
||||||
.unwrap()
|
"SELECT next_number FROM invoice_series WHERE is_active = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.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]
|
#[test]
|
||||||
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
|
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
||||||
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
|
conn.execute(
|
||||||
|
"UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1",
|
||||||
|
params![first.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
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, '')",
|
"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],
|
params![first.id],
|
||||||
@@ -791,7 +861,10 @@ mod tests {
|
|||||||
assert_eq!(row.doc_type, "tax_invoice");
|
assert_eq!(row.doc_type, "tax_invoice");
|
||||||
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
|
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
|
||||||
assert_eq!(row.taxable_paise, 731_000);
|
assert_eq!(row.taxable_paise, 731_000);
|
||||||
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
|
assert_eq!(
|
||||||
|
(row.cgst_paise, row.sgst_paise, row.igst_paise),
|
||||||
|
(65_790, 65_790, 0)
|
||||||
|
);
|
||||||
assert!(!row.reverse_charge);
|
assert!(!row.reverse_charge);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,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();
|
||||||
@@ -862,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);
|
||||||
@@ -872,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"})),
|
||||||
@@ -929,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);
|
||||||
}
|
}
|
||||||
@@ -937,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}");
|
||||||
}
|
}
|
||||||
@@ -956,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(
|
||||||
.unwrap();
|
"INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,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"),
|
||||||
];
|
];
|
||||||
@@ -1001,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()
|
||||||
.unwrap();
|
.contains("PAN"));
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code"));
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(issue(&mut conn, input(json!({})))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("state code"));
|
||||||
assert_eq!(next_number(&conn), 1);
|
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(
|
||||||
@@ -1033,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,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();
|
||||||
@@ -1082,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.
|
||||||
@@ -1095,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());
|
||||||
|
|
||||||
@@ -1111,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"));
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ pub struct PaymentInput {
|
|||||||
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
|
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
|
||||||
pub fn today_ist() -> String {
|
pub fn today_ist() -> String {
|
||||||
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
|
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
|
||||||
Utc::now().with_timezone(&ist).date_naive().format("%Y-%m-%d").to_string()
|
Utc::now()
|
||||||
|
.with_timezone(&ist)
|
||||||
|
.date_naive()
|
||||||
|
.format("%Y-%m-%d")
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
|
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
|
||||||
@@ -112,7 +116,14 @@ pub fn summary_for(
|
|||||||
params![invoice_id],
|
params![invoice_id],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)?;
|
)?;
|
||||||
Ok(derive_summary(status, rupees_to_paise(total), paid, tds, due_date, &today_ist()))
|
Ok(derive_summary(
|
||||||
|
status,
|
||||||
|
rupees_to_paise(total),
|
||||||
|
paid,
|
||||||
|
tds,
|
||||||
|
due_date,
|
||||||
|
&today_ist(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rupees(paise: i64) -> String {
|
fn rupees(paise: i64) -> String {
|
||||||
@@ -130,7 +141,9 @@ fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
|
|||||||
reference: r.get(6)?,
|
reference: r.get(6)?,
|
||||||
notes: r.get(7)?,
|
notes: r.get(7)?,
|
||||||
created_at: r.get(8)?,
|
created_at: r.get(8)?,
|
||||||
erpnext_payment_entry: r.get::<_, Option<String>>(9)?.filter(|e| !e.trim().is_empty()),
|
erpnext_payment_entry: r
|
||||||
|
.get::<_, Option<String>>(9)?
|
||||||
|
.filter(|e| !e.trim().is_empty()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +178,9 @@ pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result
|
|||||||
.map_err(db)?
|
.map_err(db)?
|
||||||
.ok_or_else(|| "Invoice not found".to_string())?;
|
.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
if status != "issued" {
|
if status != "issued" {
|
||||||
return Err(format!("Payments can only be recorded on an issued invoice (this one is {status})"));
|
return Err(format!(
|
||||||
|
"Payments can only be recorded on an issued invoice (this one is {status})"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let total_paise = rupees_to_paise(total);
|
let total_paise = rupees_to_paise(total);
|
||||||
let settled: i64 = tx
|
let settled: i64 = tx
|
||||||
@@ -203,7 +218,11 @@ pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result
|
|||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
let id = tx.last_insert_rowid();
|
let id = tx.last_insert_rowid();
|
||||||
let payment = tx
|
let payment = tx
|
||||||
.query_row(&format!("SELECT {COLS} FROM payments WHERE id = ?1"), params![id], map_payment)
|
.query_row(
|
||||||
|
&format!("SELECT {COLS} FROM payments WHERE id = ?1"),
|
||||||
|
params![id],
|
||||||
|
map_payment,
|
||||||
|
)
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
tx.commit().map_err(db)?;
|
tx.commit().map_err(db)?;
|
||||||
Ok(payment)
|
Ok(payment)
|
||||||
@@ -270,7 +289,9 @@ mod tests {
|
|||||||
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
|
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})).unwrap().id
|
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({}))
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
|
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
|
||||||
@@ -287,9 +308,11 @@ mod tests {
|
|||||||
|
|
||||||
fn summary(conn: &Connection, id: i64) -> PaymentSummary {
|
fn summary(conn: &Connection, id: i64) -> PaymentSummary {
|
||||||
let (status, total, due): (String, f64, String) = conn
|
let (status, total, due): (String, f64, String) = conn
|
||||||
.query_row("SELECT status, total, due_date FROM invoices WHERE id = ?1", params![id], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
"SELECT status, total, due_date FROM invoices WHERE id = ?1",
|
||||||
})
|
params![id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
summary_for(conn, id, &status, total, &due).unwrap()
|
summary_for(conn, id, &status, total, &due).unwrap()
|
||||||
}
|
}
|
||||||
@@ -303,7 +326,10 @@ mod tests {
|
|||||||
|
|
||||||
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
|
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
|
||||||
let s = summary(&conn, id);
|
let s = summary(&conn, id);
|
||||||
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.balance_paise), ("partially_paid", 40_000, 60_000));
|
assert_eq!(
|
||||||
|
(s.payment_status.as_str(), s.paid_paise, s.balance_paise),
|
||||||
|
("partially_paid", 40_000, 60_000)
|
||||||
|
);
|
||||||
|
|
||||||
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
|
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
|
||||||
let s = summary(&conn, id);
|
let s = summary(&conn, id);
|
||||||
@@ -317,7 +343,15 @@ mod tests {
|
|||||||
// 10% TDS: the client pays 900.00 in cash and deducts 100.00.
|
// 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();
|
record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap();
|
||||||
let s = summary(&conn, id);
|
let s = summary(&conn, id);
|
||||||
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.tds_paise, s.balance_paise), ("paid", 90_000, 10_000, 0));
|
assert_eq!(
|
||||||
|
(
|
||||||
|
s.payment_status.as_str(),
|
||||||
|
s.paid_paise,
|
||||||
|
s.tds_paise,
|
||||||
|
s.balance_paise
|
||||||
|
),
|
||||||
|
("paid", 90_000, 10_000, 0)
|
||||||
|
);
|
||||||
// TDS alone is a valid payment too.
|
// TDS alone is a valid payment too.
|
||||||
let id2 = issued(&mut conn, 500.0, "2999-01-01");
|
let id2 = issued(&mut conn, 500.0, "2999-01-01");
|
||||||
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
|
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
|
||||||
@@ -329,13 +363,18 @@ mod tests {
|
|||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
|
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
|
||||||
assert!(err.contains("more than the outstanding balance of ₹1000.00"), "{err}");
|
assert!(
|
||||||
|
err.contains("more than the outstanding balance of ₹1000.00"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
// Cash plus TDS is what counts.
|
// Cash plus TDS is what counts.
|
||||||
assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err());
|
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();
|
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();
|
let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err();
|
||||||
assert!(err.contains("₹500.00"), "{err}");
|
assert!(err.contains("₹500.00"), "{err}");
|
||||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0)).unwrap();
|
let n: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(n, 1);
|
assert_eq!(n, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +413,11 @@ mod tests {
|
|||||||
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
|
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
|
||||||
|
|
||||||
let id2 = issued(&mut conn, 1000.0, "2999-01-01");
|
let id2 = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![id2]).unwrap();
|
conn.execute(
|
||||||
|
"UPDATE invoices SET status = 'draft' WHERE id = ?1",
|
||||||
|
params![id2],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
|
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,27 +440,38 @@ mod tests {
|
|||||||
assert_eq!(summary(&conn, id).payment_status, "paid");
|
assert_eq!(summary(&conn, id).payment_status, "paid");
|
||||||
delete_payment_impl(&conn, full.id).unwrap();
|
delete_payment_impl(&conn, full.id).unwrap();
|
||||||
let s = summary(&conn, id);
|
let s = summary(&conn, id);
|
||||||
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("unpaid", 100_000));
|
assert_eq!(
|
||||||
|
(s.payment_status.as_str(), s.balance_paise),
|
||||||
|
("unpaid", 100_000)
|
||||||
|
);
|
||||||
assert!(list_payments_impl(&conn, id).unwrap().is_empty());
|
assert!(list_payments_impl(&conn, id).unwrap().is_empty());
|
||||||
assert!(delete_payment_impl(&conn, full.id).is_err());
|
assert!(delete_payment_impl(&conn, full.id).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn overdue_is_derived_from_due_date_and_balance() {
|
fn overdue_is_derived_from_due_date_and_balance() {
|
||||||
let d = |due: &str, paid: i64, tds: i64| derive_summary("issued", 100_000, paid, tds, due, "2026-05-10");
|
let d = |due: &str, paid: i64, tds: i64| {
|
||||||
|
derive_summary("issued", 100_000, paid, tds, due, "2026-05-10")
|
||||||
|
};
|
||||||
// Due today is not overdue yet; the day after is.
|
// 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-10", 0, 0).payment_status, "unpaid");
|
||||||
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
|
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
|
||||||
// Overdue wins over partially paid; the paid amount is still reported.
|
// Overdue wins over partially paid; the paid amount is still reported.
|
||||||
let s = d("2026-05-09", 10_000, 0);
|
let s = d("2026-05-09", 10_000, 0);
|
||||||
assert_eq!((s.payment_status.as_str(), s.paid_paise), ("overdue", 10_000));
|
assert_eq!(
|
||||||
|
(s.payment_status.as_str(), s.paid_paise),
|
||||||
|
("overdue", 10_000)
|
||||||
|
);
|
||||||
// A settled invoice is never overdue.
|
// A settled invoice is never overdue.
|
||||||
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
|
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
|
||||||
// No usable due date means never overdue.
|
// No usable due date means never overdue.
|
||||||
assert_eq!(d("", 0, 0).payment_status, "unpaid");
|
assert_eq!(d("", 0, 0).payment_status, "unpaid");
|
||||||
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
|
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
|
||||||
// Cancelled invoices are not owed.
|
// Cancelled invoices are not owed.
|
||||||
assert_eq!(derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise, 0);
|
assert_eq!(
|
||||||
|
derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise,
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -429,7 +483,10 @@ mod tests {
|
|||||||
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
||||||
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);
|
let s = summary(&conn, id);
|
||||||
assert_eq!((s.paid_paise, s.balance_paise, s.payment_status.as_str()), (9999, 0, "paid"));
|
assert_eq!(
|
||||||
|
(s.paid_paise, s.balance_paise, s.payment_status.as_str()),
|
||||||
|
(9999, 0, "paid")
|
||||||
|
);
|
||||||
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
|
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
|
||||||
assert_eq!(inv.total_paise, 9999);
|
assert_eq!(inv.total_paise, 9999);
|
||||||
}
|
}
|
||||||
@@ -445,7 +502,14 @@ mod tests {
|
|||||||
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
|
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
|
||||||
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
|
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
|
||||||
let ra = get(a);
|
let ra = get(a);
|
||||||
assert_eq!((ra.payment.paid_paise, ra.payment.tds_paise, ra.payment.balance_paise), (40_000, 5_000, 55_000));
|
assert_eq!(
|
||||||
|
(
|
||||||
|
ra.payment.paid_paise,
|
||||||
|
ra.payment.tds_paise,
|
||||||
|
ra.payment.balance_paise
|
||||||
|
),
|
||||||
|
(40_000, 5_000, 55_000)
|
||||||
|
);
|
||||||
assert_eq!(ra.payment.payment_status, "overdue");
|
assert_eq!(ra.payment.payment_status, "overdue");
|
||||||
assert_eq!(ra.total_paise, 100_000);
|
assert_eq!(ra.total_paise, 100_000);
|
||||||
assert_eq!(get(b).payment.payment_status, "paid");
|
assert_eq!(get(b).payment.payment_status, "paid");
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use crate::models::ItemPreset;
|
use crate::models::ItemPreset;
|
||||||
|
use crate::units;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use rusqlite::{params, Connection, Row};
|
use rusqlite::{params, Connection, Row};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
const MODES: &[&str] = &["fixed", "rate"];
|
const MODES: &[&str] = &["fixed", "rate"];
|
||||||
const UNITS: &[&str] = &["second", "minute", "hour", "session", "unit"];
|
|
||||||
/// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow.
|
/// 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 MAX_RATE_PAISE: i64 = 100_000_000_000;
|
||||||
|
|
||||||
@@ -45,9 +45,8 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
|
|||||||
if !MODES.contains(&preset.mode.as_str()) {
|
if !MODES.contains(&preset.mode.as_str()) {
|
||||||
return Err(format!("Unknown line type \"{}\"", preset.mode));
|
return Err(format!("Unknown line type \"{}\"", preset.mode));
|
||||||
}
|
}
|
||||||
if !UNITS.contains(&preset.unit.as_str()) {
|
units::validate(&preset.unit)?;
|
||||||
return Err(format!("Unknown unit \"{}\"", preset.unit));
|
let unit = units::normalize(&preset.unit);
|
||||||
}
|
|
||||||
if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) {
|
if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) {
|
||||||
return Err("Amount is out of range".into());
|
return Err("Amount is out of range".into());
|
||||||
}
|
}
|
||||||
@@ -58,7 +57,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
|
|||||||
.execute(
|
.execute(
|
||||||
"UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6
|
"UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6
|
||||||
WHERE id=?7",
|
WHERE id=?7",
|
||||||
params![description, hsn, preset.mode, preset.unit, preset.rate_paise, preset.sort_order, id],
|
params![description, hsn, preset.mode, unit, preset.rate_paise, preset.sort_order, id],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
@@ -74,7 +73,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
|
|||||||
description,
|
description,
|
||||||
hsn,
|
hsn,
|
||||||
preset.mode,
|
preset.mode,
|
||||||
preset.unit,
|
unit,
|
||||||
preset.rate_paise,
|
preset.rate_paise,
|
||||||
preset.sort_order,
|
preset.sort_order,
|
||||||
chrono::Utc::now().to_rfc3339()
|
chrono::Utc::now().to_rfc3339()
|
||||||
@@ -136,7 +135,10 @@ mod tests {
|
|||||||
let saved = save_preset_impl(&conn, p).unwrap();
|
let saved = save_preset_impl(&conn, p).unwrap();
|
||||||
assert_eq!(saved.description, "Voice-over session");
|
assert_eq!(saved.description, "Voice-over session");
|
||||||
assert_eq!(saved.hsn_sac, "998397");
|
assert_eq!(saved.hsn_sac, "998397");
|
||||||
assert_eq!((saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise), ("rate", "hour", 150_050));
|
assert_eq!(
|
||||||
|
(saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise),
|
||||||
|
("rate", "hour", 150_050)
|
||||||
|
);
|
||||||
|
|
||||||
let mut edit = saved.clone();
|
let mut edit = saved.clone();
|
||||||
edit.rate_paise = 200_000;
|
edit.rate_paise = 200_000;
|
||||||
@@ -156,6 +158,23 @@ mod tests {
|
|||||||
delete_preset_impl(&conn, 12345).unwrap();
|
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]
|
#[test]
|
||||||
fn validation_rejects_bad_presets() {
|
fn validation_rejects_bad_presets() {
|
||||||
let conn = open_in_memory().unwrap();
|
let conn = open_in_memory().unwrap();
|
||||||
@@ -166,9 +185,11 @@ mod tests {
|
|||||||
let mut p = preset("X");
|
let mut p = preset("X");
|
||||||
p.mode = "weird".into();
|
p.mode = "weird".into();
|
||||||
assert!(save_preset_impl(&conn, p).is_err());
|
assert!(save_preset_impl(&conn, p).is_err());
|
||||||
let mut p = preset("X");
|
for bad in ["", " ", "far too long a unit", "bad;unit", "₹"] {
|
||||||
p.unit = "decade".into();
|
let mut p = preset("X");
|
||||||
assert!(save_preset_impl(&conn, p).is_err());
|
p.unit = bad.into();
|
||||||
|
assert!(save_preset_impl(&conn, p).is_err(), "{bad:?}");
|
||||||
|
}
|
||||||
let mut ghost = preset("Ghost");
|
let mut ghost = preset("Ghost");
|
||||||
ghost.id = Some(77);
|
ghost.id = Some(77);
|
||||||
assert!(save_preset_impl(&conn, ghost).is_err());
|
assert!(save_preset_impl(&conn, ghost).is_err());
|
||||||
|
|||||||
@@ -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,11 +219,17 @@ 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(
|
||||||
.map_err(|e| e.to_string())?;
|
"UPDATE bank_accounts SET is_default = 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.execute(
|
||||||
|
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
||||||
|
params![next],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
tx.commit().map_err(|e| e.to_string())
|
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,8 +284,11 @@ 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(
|
||||||
.unwrap();
|
"UPDATE app_settings SET render_prefs = ?1 WHERE id = 1",
|
||||||
|
params!["{\"version\":1}"],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let settings = read(&conn);
|
let settings = read(&conn);
|
||||||
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
||||||
let json = serde_json::to_value(&settings).unwrap();
|
let json = serde_json::to_value(&settings).unwrap();
|
||||||
|
|||||||
@@ -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()`.
|
||||||
pub(crate) const LATEST_VERSION: i64 = 11;
|
pub(crate) const LATEST_VERSION: i64 = 12;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DbError {
|
pub enum DbError {
|
||||||
@@ -345,6 +345,27 @@ DROP TABLE erpnext_sync;
|
|||||||
ALTER TABLE erpnext_sync_new RENAME TO 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![
|
Migrations::new(vec![
|
||||||
M::up(SCHEMA),
|
M::up(SCHEMA),
|
||||||
@@ -358,6 +379,7 @@ fn migrations() -> Migrations<'static> {
|
|||||||
M::up(M9),
|
M::up(M9),
|
||||||
M::up(M10),
|
M::up(M10),
|
||||||
M::up(M11),
|
M::up(M11),
|
||||||
|
M::up(M12),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,8 +458,8 @@ fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Insert the first-run defaults the very first time the app is opened.
|
/// 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))?;
|
||||||
@@ -447,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],
|
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,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> {
|
||||||
@@ -504,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,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<_>>()?
|
||||||
};
|
};
|
||||||
@@ -584,7 +600,12 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R
|
|||||||
|
|
||||||
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
|
/// 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)]
|
||||||
@@ -692,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> {
|
||||||
@@ -726,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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,7 +756,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
fn m4_adds_archive_and_export_dir_columns() {
|
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).
|
||||||
@@ -755,14 +781,26 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
fn render_prefs_column_defaults_to_empty_and_round_trips() {
|
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);
|
||||||
}
|
}
|
||||||
@@ -789,15 +827,21 @@ 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);
|
||||||
@@ -818,7 +862,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let (cat, terms): (String, Option<i64>) = conn
|
let (cat, terms): (String, Option<i64>) = conn
|
||||||
.query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?)))
|
.query_row(
|
||||||
|
"SELECT gst_category, payment_terms_days FROM clients",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((cat.as_str(), terms), ("unregistered", None));
|
assert_eq!((cat.as_str(), terms), ("unregistered", None));
|
||||||
// The category CHECK rejects unknown values.
|
// The category CHECK rejects unknown values.
|
||||||
@@ -830,7 +878,14 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
#[test]
|
#[test]
|
||||||
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
|
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
|
||||||
let mut conn = Connection::open_in_memory().unwrap();
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)];
|
let all = vec![
|
||||||
|
M::up(SCHEMA),
|
||||||
|
M::up(M2),
|
||||||
|
M::up(M3),
|
||||||
|
M::up(M4),
|
||||||
|
M::up(M5),
|
||||||
|
M::up(M6),
|
||||||
|
];
|
||||||
Migrations::new(all).to_latest(&mut conn).unwrap();
|
Migrations::new(all).to_latest(&mut conn).unwrap();
|
||||||
assert_eq!(user_version(&conn), 6);
|
assert_eq!(user_version(&conn), 6);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -856,7 +911,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
.collect::<Result<_, _>>()
|
.collect::<Result<_, _>>()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rows[0].1, "Line one\nLine two");
|
assert_eq!(rows[0].1, "Line one\nLine two");
|
||||||
assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1"));
|
assert_eq!(
|
||||||
|
(rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()),
|
||||||
|
("29ABCDE1234F1Z5", "29", "PO-1")
|
||||||
|
);
|
||||||
assert_eq!(rows[0].5, "registered_regular");
|
assert_eq!(rows[0].5, "registered_regular");
|
||||||
assert_eq!(rows[0].6, "");
|
assert_eq!(rows[0].6, "");
|
||||||
assert_eq!(rows[1].5, "unregistered");
|
assert_eq!(rows[1].5, "unregistered");
|
||||||
@@ -874,25 +932,43 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((naming.as_str(), submit, attach, auto_push, create_customers), ("mirror", 0, 1, 0, 1));
|
assert_eq!(
|
||||||
|
(naming.as_str(), submit, attach, auto_push, create_customers),
|
||||||
|
("mirror", 0, 1, 0, 1)
|
||||||
|
);
|
||||||
assert_eq!(secret, "");
|
assert_eq!(secret, "");
|
||||||
assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok());
|
assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok());
|
||||||
let rows: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0)).unwrap();
|
let rows: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(rows, 1);
|
assert_eq!(rows, 1);
|
||||||
// Single row only.
|
// Single row only.
|
||||||
assert!(conn.execute("INSERT INTO erpnext_config (id) VALUES (2)", []).is_err());
|
assert!(conn
|
||||||
|
.execute("INSERT INTO erpnext_config (id) VALUES (2)", [])
|
||||||
|
.is_err());
|
||||||
conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync")
|
conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients").unwrap();
|
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients")
|
||||||
conn.prepare("SELECT erpnext_payment_entry FROM payments").unwrap();
|
.unwrap();
|
||||||
conn.prepare("SELECT erpnext_item_code FROM item_presets").unwrap();
|
conn.prepare("SELECT erpnext_payment_entry FROM payments")
|
||||||
|
.unwrap();
|
||||||
|
conn.prepare("SELECT erpnext_item_code FROM item_presets")
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
|
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
|
||||||
let mut conn = Connection::open_in_memory().unwrap();
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
let v9 = vec![
|
let v9 = vec![
|
||||||
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9),
|
M::up(SCHEMA),
|
||||||
|
M::up(M2),
|
||||||
|
M::up(M3),
|
||||||
|
M::up(M4),
|
||||||
|
M::up(M5),
|
||||||
|
M::up(M6),
|
||||||
|
M::up(M7),
|
||||||
|
M::up(M8),
|
||||||
|
M::up(M9),
|
||||||
];
|
];
|
||||||
Migrations::new(v9).to_latest(&mut conn).unwrap();
|
Migrations::new(v9).to_latest(&mut conn).unwrap();
|
||||||
assert_eq!(user_version(&conn), 9);
|
assert_eq!(user_version(&conn), 9);
|
||||||
@@ -912,37 +988,70 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
migrations().to_latest(&mut conn).unwrap();
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
|
|
||||||
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) = conn
|
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) =
|
||||||
.query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| {
|
conn.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
"SELECT name, gstin, erpnext_customer, erpnext_address FROM clients",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((name.as_str(), gstin.as_str(), customer, address), ("Acme", "29ABCDE1234F1Z5", None, None));
|
assert_eq!(
|
||||||
|
(name.as_str(), gstin.as_str(), customer, address),
|
||||||
|
("Acme", "29ABCDE1234F1Z5", None, None)
|
||||||
|
);
|
||||||
let (desc, rate, code): (String, i64, Option<String>) = conn
|
let (desc, rate, code): (String, i64, Option<String>) = conn
|
||||||
.query_row("SELECT description, rate_paise, erpnext_item_code FROM item_presets", [], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
"SELECT description, rate_paise, erpnext_item_code FROM item_presets",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
|
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
|
||||||
let (paid, entry): (i64, Option<String>) = conn
|
let (paid, entry): (i64, Option<String>) = conn
|
||||||
.query_row("SELECT amount_paise, erpnext_payment_entry FROM payments", [], |r| Ok((r.get(0)?, r.get(1)?)))
|
.query_row(
|
||||||
|
"SELECT amount_paise, erpnext_payment_entry FROM payments",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((paid, entry), (12345, None));
|
assert_eq!((paid, entry), (12345, None));
|
||||||
let number: String = conn.query_row("SELECT number FROM invoices", [], |r| r.get(0)).unwrap();
|
let number: String = conn
|
||||||
|
.query_row("SELECT number FROM invoices", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(number, "INV/2026-001");
|
assert_eq!(number, "INV/2026-001");
|
||||||
|
|
||||||
// The config row exists with defaults, and a sync row can reference the invoice.
|
// The config row exists with defaults, and a sync row can reference the invoice.
|
||||||
let enabled: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config WHERE id = 1", [], |r| r.get(0)).unwrap();
|
let enabled: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM erpnext_config WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(enabled, 1);
|
assert_eq!(enabled, 1);
|
||||||
conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap();
|
conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap();
|
||||||
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", []).is_err());
|
assert!(conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')",
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
|
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
|
||||||
let mut conn = Connection::open_in_memory().unwrap();
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
let v10 = vec![
|
let v10 = vec![
|
||||||
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), M::up(M10),
|
M::up(SCHEMA),
|
||||||
|
M::up(M2),
|
||||||
|
M::up(M3),
|
||||||
|
M::up(M4),
|
||||||
|
M::up(M5),
|
||||||
|
M::up(M6),
|
||||||
|
M::up(M7),
|
||||||
|
M::up(M8),
|
||||||
|
M::up(M9),
|
||||||
|
M::up(M10),
|
||||||
];
|
];
|
||||||
Migrations::new(v10).to_latest(&mut conn).unwrap();
|
Migrations::new(v10).to_latest(&mut conn).unwrap();
|
||||||
assert_eq!(user_version(&conn), 10);
|
assert_eq!(user_version(&conn), 10);
|
||||||
@@ -957,10 +1066,12 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err());
|
assert!(conn
|
||||||
|
.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
|
||||||
|
.is_err());
|
||||||
|
|
||||||
migrations().to_latest(&mut conn).unwrap();
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
assert_eq!(user_version(&conn), 11);
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
let row: (String, i64, String, String, String, Option<String>) = conn
|
let row: (String, i64, String, String, String, Option<String>) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1",
|
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1",
|
||||||
@@ -970,12 +1081,107 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
row,
|
row,
|
||||||
("INV/2026-001".into(), 1, "synced".into(), "abc".into(), String::new(), Some("2026-04-02T00:00:00Z".into()))
|
(
|
||||||
|
"INV/2026-001".into(),
|
||||||
|
1,
|
||||||
|
"synced".into(),
|
||||||
|
"abc".into(),
|
||||||
|
String::new(),
|
||||||
|
Some("2026-04-02T00:00:00Z".into())
|
||||||
|
)
|
||||||
);
|
);
|
||||||
conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).unwrap();
|
conn.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
|
||||||
assert!(conn.execute("UPDATE erpnext_sync SET status = 'bogus'", []).is_err());
|
.unwrap();
|
||||||
|
assert!(conn
|
||||||
|
.execute("UPDATE erpnext_sync SET status = 'bogus'", [])
|
||||||
|
.is_err());
|
||||||
// The unique key still guards one row per invoice.
|
// The unique key still guards one row per invoice.
|
||||||
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", []).is_err());
|
assert!(conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')",
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m12_rebuilds_item_presets_keeping_rows_and_allowing_new_units() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
let v11 = vec![
|
||||||
|
M::up(SCHEMA),
|
||||||
|
M::up(M2),
|
||||||
|
M::up(M3),
|
||||||
|
M::up(M4),
|
||||||
|
M::up(M5),
|
||||||
|
M::up(M6),
|
||||||
|
M::up(M7),
|
||||||
|
M::up(M8),
|
||||||
|
M::up(M9),
|
||||||
|
M::up(M10),
|
||||||
|
M::up(M11),
|
||||||
|
];
|
||||||
|
Migrations::new(v11).to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), 11);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
|
||||||
|
VALUES ('Studio', '998397', 'rate', 'hour', 150050, 3, 'then', 'ITEM-1'), ('Fee', '', 'fixed', 'unit', 500000, 0, 'then', NULL)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'km', 'now')", []).is_err());
|
||||||
|
|
||||||
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), 12);
|
||||||
|
let rows: Vec<(i64, String, String, String, String, i64, i64, String, Option<String>)> = conn
|
||||||
|
.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets ORDER BY id")
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.collect::<Result<_, _>>()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
"Studio".into(),
|
||||||
|
"998397".into(),
|
||||||
|
"rate".into(),
|
||||||
|
"hour".into(),
|
||||||
|
150050,
|
||||||
|
3,
|
||||||
|
"then".into(),
|
||||||
|
Some("ITEM-1".into())
|
||||||
|
),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
"Fee".into(),
|
||||||
|
"".into(),
|
||||||
|
"fixed".into(),
|
||||||
|
"unit".into(),
|
||||||
|
500000,
|
||||||
|
0,
|
||||||
|
"then".into(),
|
||||||
|
None
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// New units and custom text are allowed; an empty or over-long unit is not; ids keep counting up.
|
||||||
|
conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Travel', 'km', 'now')", []).unwrap();
|
||||||
|
conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Parcel', 'bundle', 'now')", []).unwrap();
|
||||||
|
assert!(conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO item_presets (description, unit, created_at) VALUES ('X', '', 'now')",
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'thirteen char', 'now')", []).is_err());
|
||||||
|
let last: i64 = conn
|
||||||
|
.query_row("SELECT MAX(id) FROM item_presets", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(last, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_v1_database(path: &Path) {
|
fn build_v1_database(path: &Path) {
|
||||||
@@ -1028,7 +1234,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
let files = backups_in(&backups);
|
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
|
||||||
@@ -1049,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",
|
||||||
[],
|
[],
|
||||||
@@ -1117,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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1126,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",
|
||||||
@@ -1156,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);
|
||||||
@@ -1175,7 +1402,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
assert_eq!(json["signaturePath"], "assets/signature-2.jpg");
|
assert_eq!(json["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();
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ impl Default for ClientOptions {
|
|||||||
/// Plain http is only for a local development site; anything else must use https.
|
/// Plain http is only for a local development site; anything else must use https.
|
||||||
pub fn is_local_dev_host(host: &str) -> bool {
|
pub fn is_local_dev_host(host: &str) -> bool {
|
||||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||||
host == "localhost" || host == "127.0.0.1" || host.ends_with(".localhost") || host.ends_with(".test")
|
host == "localhost"
|
||||||
|
|| host == "127.0.0.1"
|
||||||
|
|| host.ends_with(".localhost")
|
||||||
|
|| host.ends_with(".test")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash.
|
/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash.
|
||||||
@@ -63,10 +66,15 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
|
|||||||
raw.to_string()
|
raw.to_string()
|
||||||
} else {
|
} else {
|
||||||
let host = raw.split(['/', ':']).next().unwrap_or("");
|
let host = raw.split(['/', ':']).next().unwrap_or("");
|
||||||
let scheme = if is_local_dev_host(host) { "http" } else { "https" };
|
let scheme = if is_local_dev_host(host) {
|
||||||
|
"http"
|
||||||
|
} else {
|
||||||
|
"https"
|
||||||
|
};
|
||||||
format!("{scheme}://{raw}")
|
format!("{scheme}://{raw}")
|
||||||
};
|
};
|
||||||
let mut url = Url::parse(&with_scheme).map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
|
let mut url = Url::parse(&with_scheme)
|
||||||
|
.map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
|
||||||
let host = url
|
let host = url
|
||||||
.host_str()
|
.host_str()
|
||||||
.ok_or_else(|| ErpError::config("The ERPNext address has no host name."))?
|
.ok_or_else(|| ErpError::config("The ERPNext address has no host name."))?
|
||||||
@@ -80,10 +88,16 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
other => return Err(ErpError::config(format!("Unsupported address scheme \"{other}\"; use https://."))),
|
other => {
|
||||||
|
return Err(ErpError::config(format!(
|
||||||
|
"Unsupported address scheme \"{other}\"; use https://."
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !url.username().is_empty() || url.password().is_some() {
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
return Err(ErpError::config("Do not put credentials in the address; use the API key and secret fields."));
|
return Err(ErpError::config(
|
||||||
|
"Do not put credentials in the address; use the API key and secret fields.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
url.set_query(None);
|
url.set_query(None);
|
||||||
url.set_fragment(None);
|
url.set_fragment(None);
|
||||||
@@ -114,7 +128,9 @@ pub struct ErpClient {
|
|||||||
|
|
||||||
impl std::fmt::Debug for ErpClient {
|
impl std::fmt::Debug for ErpClient {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("ErpClient").field("base", &self.base.as_str()).finish_non_exhaustive()
|
f.debug_struct("ErpClient")
|
||||||
|
.field("base", &self.base.as_str())
|
||||||
|
.finish_non_exhaustive()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,12 +143,18 @@ impl ErpClient {
|
|||||||
opts: ClientOptions,
|
opts: ClientOptions,
|
||||||
) -> Result<Self, ErpError> {
|
) -> Result<Self, ErpError> {
|
||||||
ensure_crypto_provider();
|
ensure_crypto_provider();
|
||||||
let base = Url::parse(&normalize_base_url(base_url)?).map_err(|e| ErpError::config(e.to_string()))?;
|
let base = Url::parse(&normalize_base_url(base_url)?)
|
||||||
|
.map_err(|e| ErpError::config(e.to_string()))?;
|
||||||
if api_key.trim().is_empty() || !api_secret.is_set() {
|
if api_key.trim().is_empty() || !api_secret.is_set() {
|
||||||
return Err(ErpError::config("Enter the API key and API secret."));
|
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()))
|
let mut auth =
|
||||||
.map_err(|_| ErpError::config("The API key or secret contains characters that cannot be sent."))?;
|
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);
|
auth.set_sensitive(true);
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
@@ -152,13 +174,18 @@ impl ErpClient {
|
|||||||
let certs = reqwest::Certificate::from_pem_bundle(extra_ca_pem.trim().as_bytes())
|
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."))?;
|
.map_err(|_| ErpError::config("The extra CA certificate is not valid PEM."))?;
|
||||||
if certs.is_empty() {
|
if certs.is_empty() {
|
||||||
return Err(ErpError::config("The extra CA certificate field holds no certificate."));
|
return Err(ErpError::config(
|
||||||
|
"The extra CA certificate field holds no certificate.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
builder = builder.tls_certs_merge(certs);
|
builder = builder.tls_certs_merge(certs);
|
||||||
}
|
}
|
||||||
let http = builder
|
let http = builder.build().map_err(|e| {
|
||||||
.build()
|
ErpError::config(format!(
|
||||||
.map_err(|e| ErpError::config(format!("Could not set up the HTTP client: {}", error_chain(&e))))?;
|
"Could not set up the HTTP client: {}",
|
||||||
|
error_chain(&e)
|
||||||
|
))
|
||||||
|
})?;
|
||||||
Ok(ErpClient {
|
Ok(ErpClient {
|
||||||
http,
|
http,
|
||||||
base,
|
base,
|
||||||
@@ -168,7 +195,13 @@ impl ErpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_config(cfg: &ErpnextConfig) -> Result<Self, ErpError> {
|
pub fn from_config(cfg: &ErpnextConfig) -> Result<Self, ErpError> {
|
||||||
Self::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, &cfg.extra_ca_pem, ClientOptions::default())
|
Self::new(
|
||||||
|
&cfg.base_url,
|
||||||
|
&cfg.api_key,
|
||||||
|
&cfg.api_secret,
|
||||||
|
&cfg.extra_ca_pem,
|
||||||
|
ClientOptions::default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn base_url(&self) -> &str {
|
pub fn base_url(&self) -> &str {
|
||||||
@@ -184,22 +217,38 @@ impl ErpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET, retried on 429, 5xx and timeouts.
|
/// GET, retried on 429, 5xx and timeouts.
|
||||||
pub async fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result<Value, ErpError> {
|
pub async fn get(
|
||||||
self.send(Method::GET, segments, query, Payload::None, true).await
|
&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
|
/// 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
|
/// 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.
|
/// 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> {
|
pub async fn post(
|
||||||
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent).await
|
&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
|
/// 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
|
/// 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.
|
/// 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> {
|
pub async fn post_file(
|
||||||
self.send(Method::POST, segments, &[], Payload::Upload(upload), false).await
|
&self,
|
||||||
|
segments: &[&str],
|
||||||
|
upload: &Upload<'_>,
|
||||||
|
) -> Result<Value, ErpError> {
|
||||||
|
self.send(Method::POST, segments, &[], Payload::Upload(upload), false)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(
|
async fn send(
|
||||||
@@ -260,7 +309,8 @@ impl ErpClient {
|
|||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
return self.parse_success(&text);
|
return self.parse_success(&text);
|
||||||
}
|
}
|
||||||
let retryable = status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
|
let retryable =
|
||||||
|
status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
|
||||||
if retryable && can_retry {
|
if retryable && can_retry {
|
||||||
self.backoff(attempt, retry_after).await;
|
self.backoff(attempt, retry_after).await;
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
@@ -287,7 +337,10 @@ impl ErpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) {
|
async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) {
|
||||||
let exp = self.opts.backoff_base.saturating_mul(1u32 << attempt.min(10));
|
let exp = self
|
||||||
|
.opts
|
||||||
|
.backoff_base
|
||||||
|
.saturating_mul(1u32 << attempt.min(10));
|
||||||
let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff);
|
let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff);
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
@@ -311,7 +364,10 @@ impl ErpClient {
|
|||||||
fn map_transport(&self, e: &reqwest::Error) -> ErpError {
|
fn map_transport(&self, e: &reqwest::Error) -> ErpError {
|
||||||
let chain = error_chain(e);
|
let chain = error_chain(e);
|
||||||
let err = if e.is_timeout() {
|
let err = if e.is_timeout() {
|
||||||
ErpError::new(ErrorKind::Timeout, "The ERPNext server did not answer in time.")
|
ErpError::new(
|
||||||
|
ErrorKind::Timeout,
|
||||||
|
"The ERPNext server did not answer in time.",
|
||||||
|
)
|
||||||
} else if e.is_connect() {
|
} else if e.is_connect() {
|
||||||
let lower = chain.to_ascii_lowercase();
|
let lower = chain.to_ascii_lowercase();
|
||||||
let hint = if lower.contains("certificate") || lower.contains("unknownissuer") {
|
let hint = if lower.contains("certificate") || lower.contains("unknownissuer") {
|
||||||
@@ -319,9 +375,15 @@ impl ErpClient {
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
ErpError::new(ErrorKind::Network, format!("Could not connect to the ERPNext server ({chain}).{hint}"))
|
ErpError::new(
|
||||||
|
ErrorKind::Network,
|
||||||
|
format!("Could not connect to the ERPNext server ({chain}).{hint}"),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
ErpError::new(ErrorKind::Network, format!("The request to ERPNext failed ({chain})."))
|
ErpError::new(
|
||||||
|
ErrorKind::Network,
|
||||||
|
format!("The request to ERPNext failed ({chain})."),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
self.scrub(err)
|
self.scrub(err)
|
||||||
}
|
}
|
||||||
@@ -352,9 +414,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn https_is_accepted_and_normalised() {
|
fn https_is_accepted_and_normalised() {
|
||||||
assert_eq!(normalize_base_url(" https://Erp.Example.com/ ").unwrap(), "https://erp.example.com");
|
assert_eq!(
|
||||||
assert_eq!(normalize_base_url("erp.example.com").unwrap(), "https://erp.example.com");
|
normalize_base_url(" https://Erp.Example.com/ ").unwrap(),
|
||||||
assert_eq!(normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(), "https://erp.example.com:8443/sub");
|
"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]
|
#[test]
|
||||||
@@ -370,14 +441,26 @@ mod tests {
|
|||||||
let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}"));
|
let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}"));
|
||||||
assert!(url.starts_with("http://"), "{ok} -> {url}");
|
assert!(url.starts_with("http://"), "{ok} -> {url}");
|
||||||
}
|
}
|
||||||
for bad in ["http://erp.example.com", "http://192.168.1.10:8000", "http://localhost.evil.com", "http://notlocalhost"] {
|
for bad in [
|
||||||
|
"http://erp.example.com",
|
||||||
|
"http://192.168.1.10:8000",
|
||||||
|
"http://localhost.evil.com",
|
||||||
|
"http://notlocalhost",
|
||||||
|
] {
|
||||||
assert!(normalize_base_url(bad).is_err(), "{bad} must be refused");
|
assert!(normalize_base_url(bad).is_err(), "{bad} must be refused");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn other_schemes_credentials_and_blanks_are_refused() {
|
fn other_schemes_credentials_and_blanks_are_refused() {
|
||||||
for bad in ["ftp://erp.example.com", "file:///etc/passwd", "https://user:pw@erp.example.com", "", " ", "https://"] {
|
for bad in [
|
||||||
|
"ftp://erp.example.com",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"https://user:pw@erp.example.com",
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"https://",
|
||||||
|
] {
|
||||||
assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused");
|
assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,8 +474,14 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(value["message"], "Sample@example.com");
|
assert_eq!(value["message"], "Sample@example.com");
|
||||||
let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase();
|
let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase();
|
||||||
assert!(raw.starts_with("get /api/method/frappe.auth.get_logged_user "), "{raw}");
|
assert!(
|
||||||
assert!(raw.contains("authorization: token thekey:thesecret"), "{raw}");
|
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}");
|
assert!(raw.contains("accept: application/json"), "{raw}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,11 +489,19 @@ mod tests {
|
|||||||
async fn path_segments_are_percent_encoded_once() {
|
async fn path_segments_are_percent_encoded_once() {
|
||||||
let mock = serve(vec![reply(200, json!({ "data": [] }))]).await;
|
let mock = serve(vec![reply(200, json!({ "data": [] }))]).await;
|
||||||
client(&mock)
|
client(&mock)
|
||||||
.get(&["api", "resource", "Sales Invoice", "INV/2026-001"], &[("limit_page_length", "0".into())])
|
.get(
|
||||||
|
&["api", "resource", "Sales Invoice", "INV/2026-001"],
|
||||||
|
&[("limit_page_length", "0".into())],
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let raw = mock.requests.lock().unwrap()[0].clone();
|
let raw = mock.requests.lock().unwrap()[0].clone();
|
||||||
assert!(raw.starts_with("GET /api/resource/Sales%20Invoice/AP%2F2026-001?limit_page_length=0 "), "{raw}");
|
assert!(
|
||||||
|
raw.starts_with(
|
||||||
|
"GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "
|
||||||
|
),
|
||||||
|
"{raw}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -412,15 +509,25 @@ mod tests {
|
|||||||
let mut limited = reply(429, json!({ "message": "slow down" }));
|
let mut limited = reply(429, json!({ "message": "slow down" }));
|
||||||
limited.headers.push(("Retry-After", "0".into()));
|
limited.headers.push(("Retry-After", "0".into()));
|
||||||
let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await;
|
let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await;
|
||||||
let value = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap();
|
let value = client(&mock)
|
||||||
|
.get(&["api", "method", "ping"], &[])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(value["message"], "ok");
|
assert_eq!(value["message"], "ok");
|
||||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn persistent_500_exhausts_the_retries() {
|
async fn persistent_500_exhausts_the_retries() {
|
||||||
let mock = serve(vec![reply(500, json!({ "exception": "frappe.exceptions.ValidationError: boom" }))]).await;
|
let mock = serve(vec![reply(
|
||||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
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.kind, ErrorKind::Server);
|
||||||
assert_eq!(err.message, "boom");
|
assert_eq!(err.message, "boom");
|
||||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries");
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries");
|
||||||
@@ -428,9 +535,17 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn validation_errors_are_not_retried() {
|
async fn validation_errors_are_not_retried() {
|
||||||
let mock = serve(vec![reply(417, json!({ "_error_message": "Rate is required" }))]).await;
|
let mock = serve(vec![reply(
|
||||||
|
417,
|
||||||
|
json!({ "_error_message": "Rate is required" }),
|
||||||
|
)])
|
||||||
|
.await;
|
||||||
let err = client(&mock)
|
let err = client(&mock)
|
||||||
.post(&["api", "resource", "Sales Invoice"], &json!({ "a": 1 }), true)
|
.post(
|
||||||
|
&["api", "resource", "Sales Invoice"],
|
||||||
|
&json!({ "a": 1 }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Validation);
|
assert_eq!(err.kind, ErrorKind::Validation);
|
||||||
@@ -440,25 +555,42 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() {
|
async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() {
|
||||||
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) };
|
let slow = Reply {
|
||||||
|
delay_ms: 2_000,
|
||||||
|
..reply(200, json!({}))
|
||||||
|
};
|
||||||
let mock = serve(vec![slow]).await;
|
let mock = serve(vec![slow]).await;
|
||||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
let err = client(&mock)
|
||||||
|
.get(&["api", "method", "ping"], &[])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Timeout);
|
assert_eq!(err.kind, ErrorKind::Timeout);
|
||||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 4);
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 4);
|
||||||
|
|
||||||
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) };
|
let slow = Reply {
|
||||||
|
delay_ms: 2_000,
|
||||||
|
..reply(200, json!({}))
|
||||||
|
};
|
||||||
let mock = serve(vec![slow]).await;
|
let mock = serve(vec![slow]).await;
|
||||||
let err = client(&mock)
|
let err = client(&mock)
|
||||||
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Timeout);
|
assert_eq!(err.kind, ErrorKind::Timeout);
|
||||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 1, "a write that may have landed is not repeated");
|
assert_eq!(
|
||||||
|
mock.hits.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"a write that may have landed is not repeated"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn non_idempotent_write_retries_429_but_not_500() {
|
async fn non_idempotent_write_retries_429_but_not_500() {
|
||||||
let mock = serve(vec![reply(429, json!({})), reply(200, json!({ "data": { "name": "X" } }))]).await;
|
let mock = serve(vec![
|
||||||
|
reply(429, json!({})),
|
||||||
|
reply(200, json!({ "data": { "name": "X" } })),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
let value = client(&mock)
|
let value = client(&mock)
|
||||||
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||||
.await
|
.await
|
||||||
@@ -478,11 +610,20 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn redirects_are_reported_not_followed() {
|
async fn redirects_are_reported_not_followed() {
|
||||||
let mut moved = reply(301, json!({}));
|
let mut moved = reply(301, json!({}));
|
||||||
moved.headers.push(("Location", "https://erp.example.com/".into()));
|
moved
|
||||||
|
.headers
|
||||||
|
.push(("Location", "https://erp.example.com/".into()));
|
||||||
let mock = serve(vec![moved]).await;
|
let mock = serve(vec![moved]).await;
|
||||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
let err = client(&mock)
|
||||||
|
.get(&["api", "method", "ping"], &[])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Protocol);
|
assert_eq!(err.kind, ErrorKind::Protocol);
|
||||||
assert!(err.message.contains("https://erp.example.com/"), "{}", err.message);
|
assert!(
|
||||||
|
err.message.contains("https://erp.example.com/"),
|
||||||
|
"{}",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +632,10 @@ mod tests {
|
|||||||
let mut page = reply(200, json!({}));
|
let mut page = reply(200, json!({}));
|
||||||
page.body = "<html>login</html>".into();
|
page.body = "<html>login</html>".into();
|
||||||
let mock = serve(vec![page]).await;
|
let mock = serve(vec![page]).await;
|
||||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
let err = client(&mock)
|
||||||
|
.get(&["api", "method", "ping"], &[])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Protocol);
|
assert_eq!(err.kind, ErrorKind::Protocol);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +644,14 @@ mod tests {
|
|||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let port = listener.local_addr().unwrap().port();
|
let port = listener.local_addr().unwrap().port();
|
||||||
drop(listener);
|
drop(listener);
|
||||||
let c = ErpClient::new(&format!("http://127.0.0.1:{port}"), "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap();
|
let c = ErpClient::new(
|
||||||
|
&format!("http://127.0.0.1:{port}"),
|
||||||
|
"thekey",
|
||||||
|
&Secret::new("thesecret"),
|
||||||
|
"",
|
||||||
|
fast_opts(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Network);
|
assert_eq!(err.kind, ErrorKind::Network);
|
||||||
assert!(!err.to_string().contains("thesecret"));
|
assert!(!err.to_string().contains("thesecret"));
|
||||||
@@ -509,9 +660,30 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn credentials_are_required_and_a_bad_ca_is_refused() {
|
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(
|
||||||
assert!(ErpClient::new("https://erp.example.com", "k", &Secret::default(), "", fast_opts()).is_err());
|
"https://erp.example.com",
|
||||||
let err = ErpClient::new("https://erp.example.com", "k", &Secret::new("s"), "not a certificate", fast_opts()).unwrap_err();
|
"",
|
||||||
|
&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);
|
assert_eq!(err.kind, ErrorKind::Config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ impl Secret {
|
|||||||
|
|
||||||
impl std::fmt::Debug for Secret {
|
impl std::fmt::Debug for Secret {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.write_str(if self.is_set() { "Secret([redacted])" } else { "Secret(unset)" })
|
f.write_str(if self.is_set() {
|
||||||
|
"Secret([redacted])"
|
||||||
|
} else {
|
||||||
|
"Secret(unset)"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,18 +65,12 @@ impl NamingMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities,
|
/// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities,
|
||||||
/// which is why hours and minutes map to their own UOMs.
|
/// which is why hours, minutes, kilometres and the like map to their own UOMs (see `units::KNOWN`).
|
||||||
pub fn default_uom_map() -> BTreeMap<String, String> {
|
pub fn default_uom_map() -> BTreeMap<String, String> {
|
||||||
[
|
crate::units::KNOWN
|
||||||
("second", "Second"),
|
.iter()
|
||||||
("minute", "Minute"),
|
.map(|(id, uom)| (id.to_string(), uom.to_string()))
|
||||||
("hour", "Hour"),
|
.collect()
|
||||||
("session", "Nos"),
|
|
||||||
("unit", "Nos"),
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything stored for the integration, secret included. Backend-only.
|
/// Everything stored for the integration, secret included. Backend-only.
|
||||||
@@ -150,7 +148,9 @@ impl ErpnextConfig {
|
|||||||
if let Some(u) = self.uom_map.get(unit).filter(|u| !u.trim().is_empty()) {
|
if let Some(u) = self.uom_map.get(unit).filter(|u| !u.trim().is_empty()) {
|
||||||
return u.trim().to_string();
|
return u.trim().to_string();
|
||||||
}
|
}
|
||||||
default_uom_map().remove(unit).unwrap_or_else(|| "Nos".to_string())
|
default_uom_map()
|
||||||
|
.remove(unit)
|
||||||
|
.unwrap_or_else(|| "Nos".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view(&self) -> ErpnextConfigView {
|
pub fn view(&self) -> ErpnextConfigView {
|
||||||
@@ -182,7 +182,8 @@ impl ErpnextConfig {
|
|||||||
attach_pdf: self.attach_pdf,
|
attach_pdf: self.attach_pdf,
|
||||||
auto_push_on_issue: self.auto_push_on_issue,
|
auto_push_on_issue: self.auto_push_on_issue,
|
||||||
create_missing_customers: self.create_missing_customers,
|
create_missing_customers: self.create_missing_customers,
|
||||||
last_detect_result: serde_json::from_str(&self.last_detect_result).unwrap_or(Value::Null),
|
last_detect_result: serde_json::from_str(&self.last_detect_result)
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +297,11 @@ fn yes() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn origin_of(url: &str) -> String {
|
fn origin_of(url: &str) -> String {
|
||||||
url.split('/').take(3).collect::<Vec<_>>().join("/").to_ascii_lowercase()
|
url.split('/')
|
||||||
|
.take(3)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
.to_ascii_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ErpnextConfigInput {
|
impl ErpnextConfigInput {
|
||||||
@@ -469,8 +474,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
|
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
|
||||||
let mut base = serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" });
|
let mut base =
|
||||||
base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
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()
|
serde_json::from_value(base).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +488,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_never_prints_the_secret() {
|
fn debug_never_prints_the_secret() {
|
||||||
let cfg = ErpnextConfig { api_secret: Secret::new("s3cr3t-value"), ..Default::default() };
|
let cfg = ErpnextConfig {
|
||||||
|
api_secret: Secret::new("s3cr3t-value"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(!format!("{cfg:?}").contains("s3cr3t-value"));
|
assert!(!format!("{cfg:?}").contains("s3cr3t-value"));
|
||||||
assert!(!format!("{:?}", Secret::new("s3cr3t-value")).contains("s3cr3t-value"));
|
assert!(!format!("{:?}", Secret::new("s3cr3t-value")).contains("s3cr3t-value"));
|
||||||
}
|
}
|
||||||
@@ -489,7 +500,12 @@ mod tests {
|
|||||||
fn defaults_load_from_the_migration_row() {
|
fn defaults_load_from_the_migration_row() {
|
||||||
let cfg = load(&db()).unwrap();
|
let cfg = load(&db()).unwrap();
|
||||||
assert_eq!(cfg.naming_mode, NamingMode::Mirror);
|
assert_eq!(cfg.naming_mode, NamingMode::Mirror);
|
||||||
assert!(!cfg.submit_on_push && cfg.attach_pdf && !cfg.auto_push_on_issue && cfg.create_missing_customers);
|
assert!(
|
||||||
|
!cfg.submit_on_push
|
||||||
|
&& cfg.attach_pdf
|
||||||
|
&& !cfg.auto_push_on_issue
|
||||||
|
&& cfg.create_missing_customers
|
||||||
|
);
|
||||||
assert_eq!(cfg.uom_for("hour"), "Hour");
|
assert_eq!(cfg.uom_for("hour"), "Hour");
|
||||||
assert_eq!(cfg.uom_for("session"), "Nos");
|
assert_eq!(cfg.uom_for("session"), "Nos");
|
||||||
assert!(!cfg.api_secret.is_set());
|
assert!(!cfg.api_secret.is_set());
|
||||||
@@ -519,34 +535,52 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn blank_secret_keeps_and_explicit_values_replace_or_clear() {
|
fn blank_secret_keeps_and_explicit_values_replace_or_clear() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap();
|
let first = input(serde_json::json!({ "apiSecret": "one" }))
|
||||||
|
.resolve(&load(&conn).unwrap())
|
||||||
|
.unwrap();
|
||||||
save(&conn, &first).unwrap();
|
save(&conn, &first).unwrap();
|
||||||
|
|
||||||
let keep = input(serde_json::json!({ "apiSecret": " " })).resolve(&load(&conn).unwrap()).unwrap();
|
let keep = input(serde_json::json!({ "apiSecret": " " }))
|
||||||
|
.resolve(&load(&conn).unwrap())
|
||||||
|
.unwrap();
|
||||||
assert_eq!(keep.api_secret.expose(), "one");
|
assert_eq!(keep.api_secret.expose(), "one");
|
||||||
let replace = input(serde_json::json!({ "apiSecret": "two" })).resolve(&load(&conn).unwrap()).unwrap();
|
let replace = input(serde_json::json!({ "apiSecret": "two" }))
|
||||||
|
.resolve(&load(&conn).unwrap())
|
||||||
|
.unwrap();
|
||||||
assert_eq!(replace.api_secret.expose(), "two");
|
assert_eq!(replace.api_secret.expose(), "two");
|
||||||
let clear = input(serde_json::json!({ "clearSecret": true })).resolve(&load(&conn).unwrap()).unwrap();
|
let clear = input(serde_json::json!({ "clearSecret": true }))
|
||||||
|
.resolve(&load(&conn).unwrap())
|
||||||
|
.unwrap();
|
||||||
assert!(!clear.api_secret.is_set());
|
assert!(!clear.api_secret.is_set());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stored_secret_is_not_reused_for_another_server() {
|
fn stored_secret_is_not_reused_for_another_server() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap();
|
let first = input(serde_json::json!({ "apiSecret": "one" }))
|
||||||
|
.resolve(&load(&conn).unwrap())
|
||||||
|
.unwrap();
|
||||||
save(&conn, &first).unwrap();
|
save(&conn, &first).unwrap();
|
||||||
let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" })).resolve(&load(&conn).unwrap());
|
let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" }))
|
||||||
|
.resolve(&load(&conn).unwrap());
|
||||||
assert!(moved.unwrap_err().contains("API secret"));
|
assert!(moved.unwrap_err().contains("API secret"));
|
||||||
// The same server with a different path or case is still the same origin.
|
// The same server with a different path or case is still the same origin.
|
||||||
let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" })).resolve(&load(&conn).unwrap());
|
let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" }))
|
||||||
|
.resolve(&load(&conn).unwrap());
|
||||||
assert!(same.is_ok());
|
assert!(same.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_address_or_naming_mode_is_rejected() {
|
fn invalid_address_or_naming_mode_is_rejected() {
|
||||||
let stored = ErpnextConfig::default();
|
let stored = ErpnextConfig::default();
|
||||||
assert!(input(serde_json::json!({ "baseUrl": "http://erp.example.com" })).resolve(&stored).is_err());
|
assert!(
|
||||||
assert!(input(serde_json::json!({ "namingMode": "random" })).resolve(&stored).is_err());
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -20,12 +20,19 @@ impl Version {
|
|||||||
/// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored.
|
/// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored.
|
||||||
pub fn parse(s: &str) -> Option<Version> {
|
pub fn parse(s: &str) -> Option<Version> {
|
||||||
let s = s.trim().trim_start_matches(['v', 'V']);
|
let s = s.trim().trim_start_matches(['v', 'V']);
|
||||||
let numeric: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
|
let numeric: String = s
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_digit() || *c == '.')
|
||||||
|
.collect();
|
||||||
let mut parts = numeric.split('.').filter(|p| !p.is_empty());
|
let mut parts = numeric.split('.').filter(|p| !p.is_empty());
|
||||||
let major = parts.next()?.parse().ok()?;
|
let major = parts.next()?.parse().ok()?;
|
||||||
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
Some(Version { major, minor, patch })
|
Some(Version {
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
patch,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +66,10 @@ pub fn parse_versions(value: &Value) -> ServerVersions {
|
|||||||
let apps = value.get("message").unwrap_or(value);
|
let apps = value.get("message").unwrap_or(value);
|
||||||
let version_of = |app: &str| -> Option<String> {
|
let version_of = |app: &str| -> Option<String> {
|
||||||
let entry = apps.get(app)?;
|
let entry = apps.get(app)?;
|
||||||
let v = entry.get("version").and_then(Value::as_str).or_else(|| entry.as_str())?;
|
let v = entry
|
||||||
|
.get("version")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| entry.as_str())?;
|
||||||
let v = v.trim();
|
let v = v.trim();
|
||||||
(!v.is_empty()).then(|| v.to_string())
|
(!v.is_empty()).then(|| v.to_string())
|
||||||
};
|
};
|
||||||
@@ -77,7 +87,9 @@ pub const IC_MAX_NUMBER_LEN: usize = 16;
|
|||||||
/// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`.
|
/// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`.
|
||||||
pub fn ic_number_ok(number: &str) -> bool {
|
pub fn ic_number_ok(number: &str) -> bool {
|
||||||
let mut chars = number.chars();
|
let mut chars = number.chars();
|
||||||
let Some(first) = chars.next() else { return false };
|
let Some(first) = chars.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
first.is_alphanumeric()
|
first.is_alphanumeric()
|
||||||
&& number.chars().count() <= IC_MAX_NUMBER_LEN
|
&& number.chars().count() <= IC_MAX_NUMBER_LEN
|
||||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/')
|
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/')
|
||||||
@@ -158,13 +170,22 @@ fn item(row: &Value, label_key: &str, detail: String) -> Option<OptionItem> {
|
|||||||
let label = text(row, label_key);
|
let label = text(row, label_key);
|
||||||
Some(OptionItem {
|
Some(OptionItem {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
label: if label.is_empty() { name.to_string() } else { label.to_string() },
|
label: if label.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
label.to_string()
|
||||||
|
},
|
||||||
detail,
|
detail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn join(parts: &[&str]) -> String {
|
fn join(parts: &[&str]) -> String {
|
||||||
parts.iter().filter(|p| !p.is_empty()).cloned().collect::<Vec<_>>().join(" · ")
|
parts
|
||||||
|
.iter()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank.
|
/// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank.
|
||||||
@@ -173,23 +194,41 @@ pub fn parse_naming_series(doctype_response: &Value) -> Vec<OptionItem> {
|
|||||||
.get("docs")
|
.get("docs")
|
||||||
.or_else(|| doctype_response.get("message").and_then(|m| m.get("docs")))
|
.or_else(|| doctype_response.get("message").and_then(|m| m.get("docs")))
|
||||||
.and_then(Value::as_array);
|
.and_then(Value::as_array);
|
||||||
let Some(doc) = docs.and_then(|d| d.first()) else { return Vec::new() };
|
let Some(doc) = docs.and_then(|d| d.first()) else {
|
||||||
let Some(fields) = doc.get("fields").and_then(Value::as_array) else { return Vec::new() };
|
return Vec::new();
|
||||||
let Some(field) = fields.iter().find(|f| text(f, "fieldname") == "naming_series") else {
|
};
|
||||||
|
let Some(fields) = doc.get("fields").and_then(Value::as_array) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(field) = fields
|
||||||
|
.iter()
|
||||||
|
.find(|f| text(f, "fieldname") == "naming_series")
|
||||||
|
else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
text(field, "options")
|
text(field, "options")
|
||||||
.lines()
|
.lines()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|l| !l.is_empty())
|
.filter(|l| !l.is_empty())
|
||||||
.map(|l| OptionItem { name: l.to_string(), label: l.to_string(), detail: format!("e.g. {} digits", naming_series_expanded_len(l)) })
|
.map(|l| OptionItem {
|
||||||
|
name: l.to_string(),
|
||||||
|
label: l.to_string(),
|
||||||
|
detail: format!("e.g. {} digits", naming_series_expanded_len(l)),
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ErpClient {
|
impl ErpClient {
|
||||||
pub async fn get_logged_user(&self) -> Result<String, ErpError> {
|
pub async fn get_logged_user(&self) -> Result<String, ErpError> {
|
||||||
let v = self.get(&["api", "method", "frappe.auth.get_logged_user"], &[]).await?;
|
let v = self
|
||||||
let user = v.get("message").and_then(Value::as_str).unwrap_or("").trim().to_string();
|
.get(&["api", "method", "frappe.auth.get_logged_user"], &[])
|
||||||
|
.await?;
|
||||||
|
let user = v
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
if user.is_empty() || user.eq_ignore_ascii_case("guest") {
|
if user.is_empty() || user.eq_ignore_ascii_case("guest") {
|
||||||
return Err(ErpError {
|
return Err(ErpError {
|
||||||
kind: ErrorKind::Auth,
|
kind: ErrorKind::Auth,
|
||||||
@@ -202,7 +241,12 @@ impl ErpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_versions(&self) -> Result<ServerVersions, ErpError> {
|
pub async fn get_versions(&self) -> Result<ServerVersions, ErpError> {
|
||||||
let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?;
|
let v = self
|
||||||
|
.get(
|
||||||
|
&["api", "method", "frappe.utils.change_log.get_versions"],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(parse_versions(&v))
|
Ok(parse_versions(&v))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +267,10 @@ impl ErpClient {
|
|||||||
query.push(("filters", filters.to_string()));
|
query.push(("filters", filters.to_string()));
|
||||||
}
|
}
|
||||||
let v = self.get(&["api", "resource", doctype], &query).await?;
|
let v = self.get(&["api", "resource", doctype], &query).await?;
|
||||||
Ok(v.get("data").and_then(Value::as_array).cloned().unwrap_or_default())
|
Ok(v.get("data")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_options(&self, company: &str) -> Result<ErpnextOptions, ErpError> {
|
pub async fn load_options(&self, company: &str) -> Result<ErpnextOptions, ErpError> {
|
||||||
@@ -234,52 +281,119 @@ impl ErpClient {
|
|||||||
($list:literal, $target:expr, $call:expr) => {
|
($list:literal, $target:expr, $call:expr) => {
|
||||||
match $call.await {
|
match $call.await {
|
||||||
Ok(rows) => $target = rows,
|
Ok(rows) => $target = rows,
|
||||||
Err(e) if matches!(e.kind, ErrorKind::Auth | ErrorKind::Network | ErrorKind::Timeout | ErrorKind::Config | ErrorKind::Protocol) => {
|
Err(e)
|
||||||
|
if matches!(
|
||||||
|
e.kind,
|
||||||
|
ErrorKind::Auth
|
||||||
|
| ErrorKind::Network
|
||||||
|
| ErrorKind::Timeout
|
||||||
|
| ErrorKind::Config
|
||||||
|
| ErrorKind::Protocol
|
||||||
|
) =>
|
||||||
|
{
|
||||||
return Err(e)
|
return Err(e)
|
||||||
}
|
}
|
||||||
Err(e) => out.errors.push(OptionError { list: $list.to_string(), message: e.to_string() }),
|
Err(e) => out.errors.push(OptionError {
|
||||||
|
list: $list.to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
load!("companies", out.companies, async {
|
load!("companies", out.companies, async {
|
||||||
self.list_resource("Company", &["name", "company_name", "abbr", "country", "default_currency"], Value::Null, "name asc")
|
self.list_resource(
|
||||||
.await
|
"Company",
|
||||||
.map(|rows| {
|
&[
|
||||||
rows.iter()
|
"name",
|
||||||
.filter_map(|r| item(r, "company_name", join(&[text(r, "abbr"), text(r, "default_currency")])))
|
"company_name",
|
||||||
.collect()
|
"abbr",
|
||||||
})
|
"country",
|
||||||
|
"default_currency",
|
||||||
|
],
|
||||||
|
Value::Null,
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| {
|
||||||
|
item(
|
||||||
|
r,
|
||||||
|
"company_name",
|
||||||
|
join(&[text(r, "abbr"), text(r, "default_currency")]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("itemGroups", out.item_groups, async {
|
load!("itemGroups", out.item_groups, async {
|
||||||
self.list_resource("Item Group", &["name"], Value::Null, "name asc")
|
self.list_resource("Item Group", &["name"], Value::Null, "name asc")
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("uoms", out.uoms, async {
|
load!("uoms", out.uoms, async {
|
||||||
self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc")
|
self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc")
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("priceLists", out.price_lists, async {
|
load!("priceLists", out.price_lists, async {
|
||||||
self.list_resource("Price List", &["name"], json!([["enabled", "=", 1], ["selling", "=", 1]]), "name asc")
|
self.list_resource(
|
||||||
.await
|
"Price List",
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
&["name"],
|
||||||
|
json!([["enabled", "=", 1], ["selling", "=", 1]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("customerGroups", out.customer_groups, async {
|
load!("customerGroups", out.customer_groups, async {
|
||||||
self.list_resource("Customer Group", &["name"], json!([["is_group", "=", 0]]), "name asc")
|
self.list_resource(
|
||||||
.await
|
"Customer Group",
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
&["name"],
|
||||||
|
json!([["is_group", "=", 0]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("territories", out.territories, async {
|
load!("territories", out.territories, async {
|
||||||
self.list_resource("Territory", &["name"], json!([["is_group", "=", 0]]), "name asc")
|
self.list_resource(
|
||||||
.await
|
"Territory",
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
&["name"],
|
||||||
|
json!([["is_group", "=", 0]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("namingSeries", out.naming_series, async {
|
load!("namingSeries", out.naming_series, async {
|
||||||
self.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Sales Invoice".to_string())])
|
self.get(
|
||||||
.await
|
&["api", "method", "frappe.desk.form.load.getdoctype"],
|
||||||
.map(|v| parse_naming_series(&v))
|
&[("doctype", "Sales Invoice".to_string())],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|v| parse_naming_series(&v))
|
||||||
});
|
});
|
||||||
|
|
||||||
if company.is_empty() {
|
if company.is_empty() {
|
||||||
@@ -291,11 +405,32 @@ impl ErpClient {
|
|||||||
["Dynamic Link", "link_name", "=", company],
|
["Dynamic Link", "link_name", "=", company],
|
||||||
["disabled", "=", 0]
|
["disabled", "=", 0]
|
||||||
]);
|
]);
|
||||||
let base = ["name", "address_title", "address_line1", "city", "state", "pincode"];
|
let base = [
|
||||||
let with_gstin = ["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"];
|
"name",
|
||||||
|
"address_title",
|
||||||
|
"address_line1",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"pincode",
|
||||||
|
];
|
||||||
|
let with_gstin = [
|
||||||
|
"name",
|
||||||
|
"address_title",
|
||||||
|
"address_line1",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"pincode",
|
||||||
|
"gstin",
|
||||||
|
];
|
||||||
// `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted").
|
// `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted").
|
||||||
let rows = match self.list_resource("Address", &with_gstin, filters.clone(), "name asc").await {
|
let rows = match self
|
||||||
Err(e) if e.kind == ErrorKind::Validation => self.list_resource("Address", &base, filters, "name asc").await?,
|
.list_resource("Address", &with_gstin, filters.clone(), "name asc")
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Err(e) if e.kind == ErrorKind::Validation => {
|
||||||
|
self.list_resource("Address", &base, filters, "name asc")
|
||||||
|
.await?
|
||||||
|
}
|
||||||
other => other?,
|
other => other?,
|
||||||
};
|
};
|
||||||
Ok::<_, ErpError>(
|
Ok::<_, ErpError>(
|
||||||
@@ -304,7 +439,13 @@ impl ErpClient {
|
|||||||
item(
|
item(
|
||||||
r,
|
r,
|
||||||
"address_title",
|
"address_title",
|
||||||
join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]),
|
join(&[
|
||||||
|
text(r, "address_line1"),
|
||||||
|
text(r, "city"),
|
||||||
|
text(r, "state"),
|
||||||
|
text(r, "pincode"),
|
||||||
|
text(r, "gstin"),
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -314,21 +455,39 @@ impl ErpClient {
|
|||||||
self.list_resource(
|
self.list_resource(
|
||||||
"Account",
|
"Account",
|
||||||
&["name", "account_name", "account_type"],
|
&["name", "account_name", "account_type"],
|
||||||
json!([["company", "=", company], ["root_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]),
|
json!([
|
||||||
|
["company", "=", company],
|
||||||
|
["root_type", "=", "Income"],
|
||||||
|
["is_group", "=", 0],
|
||||||
|
["disabled", "=", 0]
|
||||||
|
]),
|
||||||
"name asc",
|
"name asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "account_name", text(r, "account_type").to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("taxAccounts", out.tax_accounts, async {
|
load!("taxAccounts", out.tax_accounts, async {
|
||||||
self.list_resource(
|
self.list_resource(
|
||||||
"Account",
|
"Account",
|
||||||
&["name", "account_name", "account_type"],
|
&["name", "account_name", "account_type"],
|
||||||
json!([["company", "=", company], ["account_type", "=", "Tax"], ["is_group", "=", 0], ["disabled", "=", 0]]),
|
json!([
|
||||||
|
["company", "=", company],
|
||||||
|
["account_type", "=", "Tax"],
|
||||||
|
["is_group", "=", 0],
|
||||||
|
["disabled", "=", 0]
|
||||||
|
]),
|
||||||
"name asc",
|
"name asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "account_name", text(r, "account_type").to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("taxTemplates", out.tax_templates, async {
|
load!("taxTemplates", out.tax_templates, async {
|
||||||
self.list_resource(
|
self.list_resource(
|
||||||
@@ -338,17 +497,29 @@ impl ErpClient {
|
|||||||
"name asc",
|
"name asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "title", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "title", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("costCenters", out.cost_centers, async {
|
load!("costCenters", out.cost_centers, async {
|
||||||
self.list_resource(
|
self.list_resource(
|
||||||
"Cost Center",
|
"Cost Center",
|
||||||
&["name", "cost_center_name"],
|
&["name", "cost_center_name"],
|
||||||
json!([["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]),
|
json!([
|
||||||
|
["company", "=", company],
|
||||||
|
["is_group", "=", 0],
|
||||||
|
["disabled", "=", 0]
|
||||||
|
]),
|
||||||
"name asc",
|
"name asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "cost_center_name", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "cost_center_name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
@@ -363,7 +534,10 @@ pub struct Warning {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn warn(code: &str, message: impl Into<String>) -> Warning {
|
fn warn(code: &str, message: impl Into<String>) -> Warning {
|
||||||
Warning { code: code.to_string(), message: message.into() }
|
Warning {
|
||||||
|
code: code.to_string(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Facts from the local database that the warnings need.
|
/// Facts from the local database that the warnings need.
|
||||||
@@ -390,7 +564,9 @@ pub struct RemoteFacts {
|
|||||||
pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
|
pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
|
||||||
let for_company: Vec<&Value> = rows
|
let for_company: Vec<&Value> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output"))
|
.filter(|r| {
|
||||||
|
text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output")
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if for_company.is_empty() {
|
if for_company.is_empty() {
|
||||||
return vec![warn(
|
return vec![warn(
|
||||||
@@ -419,7 +595,11 @@ pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pure: turns the configuration and the facts gathered into the list shown after a connection test.
|
/// Pure: turns the configuration and the facts gathered into the list shown after a connection test.
|
||||||
pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFacts) -> Vec<Warning> {
|
pub fn build_warnings(
|
||||||
|
cfg: &ErpnextConfig,
|
||||||
|
local: &LocalFacts,
|
||||||
|
remote: &RemoteFacts,
|
||||||
|
) -> Vec<Warning> {
|
||||||
let mut w = Vec::new();
|
let mut w = Vec::new();
|
||||||
let features = remote.versions.as_ref().map(feature_switches);
|
let features = remote.versions.as_ref().map(feature_switches);
|
||||||
let ic = features.is_some_and(|f| f.india_compliance);
|
let ic = features.is_some_and(|f| f.india_compliance);
|
||||||
@@ -449,11 +629,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if cfg.naming_series.is_empty() {
|
} else if cfg.naming_series.is_empty() {
|
||||||
w.push(warn("naming_series_missing", "ERPNext series naming is selected but no naming series is chosen."));
|
w.push(warn(
|
||||||
|
"naming_series_missing",
|
||||||
|
"ERPNext series naming is selected but no naming series is chosen.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if local.vendor_registered {
|
if local.vendor_registered {
|
||||||
if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty() {
|
if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty()
|
||||||
|
{
|
||||||
w.push(warn(
|
w.push(warn(
|
||||||
"tax_accounts_missing",
|
"tax_accounts_missing",
|
||||||
"The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.",
|
"The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.",
|
||||||
@@ -479,9 +663,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.company_address.is_empty() {
|
if cfg.company_address.is_empty() {
|
||||||
w.push(warn("company_address_missing", "India Compliance needs a company address on every invoice; none is selected."));
|
w.push(warn(
|
||||||
|
"company_address_missing",
|
||||||
|
"India Compliance needs a company address on every invoice; none is selected.",
|
||||||
|
));
|
||||||
} else if remote.company_address_count == Some(0) {
|
} else if remote.company_address_count == Some(0) {
|
||||||
w.push(warn("company_address_missing", "No address is linked to the company on the site."));
|
w.push(warn(
|
||||||
|
"company_address_missing",
|
||||||
|
"No address is linked to the company on the site.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
match cfg.naming_mode {
|
match cfg.naming_mode {
|
||||||
NamingMode::Mirror => {
|
NamingMode::Mirror => {
|
||||||
@@ -533,14 +723,21 @@ pub struct ConnectionTest {
|
|||||||
|
|
||||||
/// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only
|
/// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only
|
||||||
/// produce warnings.
|
/// produce warnings.
|
||||||
pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &LocalFacts) -> Result<ConnectionTest, ErpError> {
|
pub async fn test_connection(
|
||||||
|
client: &ErpClient,
|
||||||
|
cfg: &ErpnextConfig,
|
||||||
|
local: &LocalFacts,
|
||||||
|
) -> Result<ConnectionTest, ErpError> {
|
||||||
let user = client.get_logged_user().await?;
|
let user = client.get_logged_user().await?;
|
||||||
let versions = match client.get_versions().await {
|
let versions = match client.get_versions().await {
|
||||||
Ok(v) => Some(v),
|
Ok(v) => Some(v),
|
||||||
Err(e) if e.kind == ErrorKind::Auth => return Err(e),
|
Err(e) if e.kind == ErrorKind::Auth => return Err(e),
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
let mut remote = RemoteFacts { versions, ..Default::default() };
|
let mut remote = RemoteFacts {
|
||||||
|
versions,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let features = remote.versions.as_ref().map(feature_switches);
|
let features = remote.versions.as_ref().map(feature_switches);
|
||||||
if features.is_some_and(|f| f.india_compliance) {
|
if features.is_some_and(|f| f.india_compliance) {
|
||||||
if !cfg.company.is_empty() {
|
if !cfg.company.is_empty() {
|
||||||
@@ -548,7 +745,11 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
|
|||||||
.list_resource(
|
.list_resource(
|
||||||
"Address",
|
"Address",
|
||||||
&["name"],
|
&["name"],
|
||||||
json!([["Dynamic Link", "link_doctype", "=", "Company"], ["Dynamic Link", "link_name", "=", cfg.company], ["disabled", "=", 0]]),
|
json!([
|
||||||
|
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||||
|
["Dynamic Link", "link_name", "=", cfg.company],
|
||||||
|
["disabled", "=", 0]
|
||||||
|
]),
|
||||||
"name asc",
|
"name asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -560,7 +761,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
|
|||||||
client
|
client
|
||||||
.get(&["api", "resource", "GST Settings", "GST Settings"], &[])
|
.get(&["api", "resource", "GST Settings", "GST Settings"], &[])
|
||||||
.await
|
.await
|
||||||
.map(|v| v.get("data").and_then(|d| d.get("gst_accounts")).and_then(Value::as_array).cloned().unwrap_or_default())
|
.map(|v| {
|
||||||
|
v.get("data")
|
||||||
|
.and_then(|d| d.get("gst_accounts"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
.map_err(|e| e.to_string()),
|
.map_err(|e| e.to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -568,7 +775,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
|
|||||||
let warnings = build_warnings(cfg, local, &remote);
|
let warnings = build_warnings(cfg, local, &remote);
|
||||||
let versions = remote.versions.unwrap_or_default();
|
let versions = remote.versions.unwrap_or_default();
|
||||||
let features = feature_switches(&versions);
|
let features = feature_switches(&versions);
|
||||||
Ok(ConnectionTest { user, india_compliance: features.india_compliance, features, versions, warnings })
|
Ok(ConnectionTest {
|
||||||
|
user,
|
||||||
|
india_compliance: features.india_compliance,
|
||||||
|
features,
|
||||||
|
versions,
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -608,10 +821,38 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn versions_parse_leniently() {
|
fn versions_parse_leniently() {
|
||||||
assert_eq!(Version::parse("15.73.2"), Some(Version { major: 15, minor: 73, patch: 2 }));
|
assert_eq!(
|
||||||
assert_eq!(Version::parse("v16.0.0-dev"), Some(Version { major: 16, minor: 0, patch: 0 }));
|
Version::parse("15.73.2"),
|
||||||
assert_eq!(Version::parse("15.1"), Some(Version { major: 15, minor: 1, patch: 0 }));
|
Some(Version {
|
||||||
assert_eq!(Version::parse("15.73.0-beta.2"), Some(Version { major: 15, minor: 73, patch: 0 }));
|
major: 15,
|
||||||
|
minor: 73,
|
||||||
|
patch: 2
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Version::parse("v16.0.0-dev"),
|
||||||
|
Some(Version {
|
||||||
|
major: 16,
|
||||||
|
minor: 0,
|
||||||
|
patch: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Version::parse("15.1"),
|
||||||
|
Some(Version {
|
||||||
|
major: 15,
|
||||||
|
minor: 1,
|
||||||
|
patch: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Version::parse("15.73.0-beta.2"),
|
||||||
|
Some(Version {
|
||||||
|
major: 15,
|
||||||
|
minor: 73,
|
||||||
|
patch: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
assert_eq!(Version::parse("unknown"), None);
|
assert_eq!(Version::parse("unknown"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +881,11 @@ mod tests {
|
|||||||
assert_eq!(v.frappe.as_deref(), Some("15.73.0"));
|
assert_eq!(v.frappe.as_deref(), Some("15.73.0"));
|
||||||
assert_eq!(v.erpnext.as_deref(), Some("15.121.6"));
|
assert_eq!(v.erpnext.as_deref(), Some("15.121.6"));
|
||||||
assert_eq!(v.india_compliance.as_deref(), Some("15.9.0"));
|
assert_eq!(v.india_compliance.as_deref(), Some("15.9.0"));
|
||||||
assert!(parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } })).india_compliance.is_none());
|
assert!(
|
||||||
|
parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } }))
|
||||||
|
.india_compliance
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -648,7 +893,16 @@ mod tests {
|
|||||||
for ok in ["INV/2026-001", "A", "1234567890123456", "AP-24-25/0001"] {
|
for ok in ["INV/2026-001", "A", "1234567890123456", "AP-24-25/0001"] {
|
||||||
assert!(ic_number_ok(ok), "{ok}");
|
assert!(ic_number_ok(ok), "{ok}");
|
||||||
}
|
}
|
||||||
for bad in ["", "12345678901234567", "_AB", "AB 01", "AB_01", "-AB", "/AB", "AB#1"] {
|
for bad in [
|
||||||
|
"",
|
||||||
|
"12345678901234567",
|
||||||
|
"_AB",
|
||||||
|
"AB 01",
|
||||||
|
"AB_01",
|
||||||
|
"-AB",
|
||||||
|
"/AB",
|
||||||
|
"AB#1",
|
||||||
|
] {
|
||||||
assert!(!ic_number_ok(bad), "{bad}");
|
assert!(!ic_number_ok(bad), "{bad}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,9 +910,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn naming_series_is_expanded_before_measuring() {
|
fn naming_series_is_expanded_before_measuring() {
|
||||||
assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5);
|
assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5);
|
||||||
assert_eq!(naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"), 9 + 4 + 1 + 5);
|
assert_eq!(
|
||||||
assert_eq!(naming_series_expanded_len("INV-.MM.-.DD.-.####"), 4 + 2 + 1 + 2 + 1 + 4);
|
naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"),
|
||||||
assert_eq!(naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"), 15 + 4 + 1 + 5);
|
9 + 4 + 1 + 5
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
naming_series_expanded_len("INV-.MM.-.DD.-.####"),
|
||||||
|
4 + 2 + 1 + 2 + 1 + 4
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"),
|
||||||
|
15 + 4 + 1 + 5
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -668,33 +931,54 @@ mod tests {
|
|||||||
{ "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" }
|
{ "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" }
|
||||||
]}]});
|
]}]});
|
||||||
let items = parse_naming_series(&resp);
|
let items = parse_naming_series(&resp);
|
||||||
assert_eq!(items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(), ["SINV-.YY.-", "ACC-SINV-.YYYY.-"]);
|
assert_eq!(
|
||||||
|
items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(),
|
||||||
|
["SINV-.YY.-", "ACC-SINV-.YYYY.-"]
|
||||||
|
);
|
||||||
assert!(parse_naming_series(&json!({})).is_empty());
|
assert!(parse_naming_series(&json!({})).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_healthy_ic_setup_has_no_warnings() {
|
fn a_healthy_ic_setup_has_no_warnings() {
|
||||||
let local = LocalFacts { vendor_registered: true, invalid_numbers: vec![], next_number: Some("INV/2026-001".into()) };
|
let local = LocalFacts {
|
||||||
assert_eq!(build_warnings(&cfg(), &local, &ic_versions("15.73.0")), vec![]);
|
vendor_registered: true,
|
||||||
|
invalid_numbers: vec![],
|
||||||
|
next_number: Some("INV/2026-001".into()),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
build_warnings(&cfg(), &local, &ic_versions("15.73.0")),
|
||||||
|
vec![]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gst_accounts_not_configured_or_mismatched_are_flagged() {
|
fn gst_accounts_not_configured_or_mismatched_are_flagged() {
|
||||||
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
let local = LocalFacts {
|
||||||
|
vendor_registered: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let mut remote = ic_versions("15.73.0");
|
let mut remote = ic_versions("15.73.0");
|
||||||
remote.gst_accounts = Some(Ok(vec![]));
|
remote.gst_accounts = Some(Ok(vec![]));
|
||||||
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_accounts_not_configured"]);
|
assert_eq!(
|
||||||
|
codes(&build_warnings(&cfg(), &local, &remote)),
|
||||||
|
["gst_accounts_not_configured"]
|
||||||
|
);
|
||||||
|
|
||||||
let mut remote = ic_versions("15.73.0");
|
let mut remote = ic_versions("15.73.0");
|
||||||
remote.gst_accounts = Some(Ok(vec![json!({ "company": "Test Co", "account_type": "Output",
|
remote.gst_accounts = Some(Ok(vec![
|
||||||
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" })]));
|
json!({ "company": "Test Co", "account_type": "Output",
|
||||||
|
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" }),
|
||||||
|
]));
|
||||||
let w = build_warnings(&cfg(), &local, &remote);
|
let w = build_warnings(&cfg(), &local, &remote);
|
||||||
assert_eq!(codes(&w), ["gst_account_mismatch"]);
|
assert_eq!(codes(&w), ["gst_account_mismatch"]);
|
||||||
assert!(w[0].message.contains("SGST"));
|
assert!(w[0].message.contains("SGST"));
|
||||||
|
|
||||||
let mut remote = ic_versions("15.73.0");
|
let mut remote = ic_versions("15.73.0");
|
||||||
remote.gst_accounts = Some(Err("HTTP 403".into()));
|
remote.gst_accounts = Some(Err("HTTP 403".into()));
|
||||||
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_settings_unreadable"]);
|
assert_eq!(
|
||||||
|
codes(&build_warnings(&cfg(), &local, &remote)),
|
||||||
|
["gst_settings_unreadable"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -727,20 +1011,45 @@ mod tests {
|
|||||||
|
|
||||||
let mut remote = ic_versions("15.73.0");
|
let mut remote = ic_versions("15.73.0");
|
||||||
remote.company_address_count = Some(0);
|
remote.company_address_count = Some(0);
|
||||||
assert_eq!(codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)), ["company_address_missing"]);
|
assert_eq!(
|
||||||
|
codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)),
|
||||||
|
["company_address_missing"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn required_settings_and_unknown_versions_are_flagged() {
|
fn required_settings_and_unknown_versions_are_flagged() {
|
||||||
let w = build_warnings(&ErpnextConfig::default(), &LocalFacts { vendor_registered: true, ..Default::default() }, &RemoteFacts::default());
|
let w = build_warnings(
|
||||||
assert_eq!(codes(&w), ["versions_unknown", "company_missing", "income_account_missing", "tax_accounts_missing"]);
|
&ErpnextConfig::default(),
|
||||||
|
&LocalFacts {
|
||||||
|
vendor_registered: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&RemoteFacts::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
codes(&w),
|
||||||
|
[
|
||||||
|
"versions_unknown",
|
||||||
|
"company_missing",
|
||||||
|
"income_account_missing",
|
||||||
|
"tax_accounts_missing"
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registered_vendor_without_ic_gets_a_notice() {
|
fn registered_vendor_without_ic_gets_a_notice() {
|
||||||
let mut remote = ic_versions("15.73.0");
|
let mut remote = ic_versions("15.73.0");
|
||||||
remote.versions.as_mut().unwrap().india_compliance = None;
|
remote.versions.as_mut().unwrap().india_compliance = None;
|
||||||
let w = build_warnings(&cfg(), &LocalFacts { vendor_registered: true, ..Default::default() }, &remote);
|
let w = build_warnings(
|
||||||
|
&cfg(),
|
||||||
|
&LocalFacts {
|
||||||
|
vendor_registered: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&remote,
|
||||||
|
);
|
||||||
assert_eq!(codes(&w), ["ic_not_detected"]);
|
assert_eq!(codes(&w), ["ic_not_detected"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,27 +1062,51 @@ mod tests {
|
|||||||
if path.contains("get_logged_user") {
|
if path.contains("get_logged_user") {
|
||||||
reply(200, json!({ "message": "integration@example.com" }))
|
reply(200, json!({ "message": "integration@example.com" }))
|
||||||
} else if path.contains("get_versions") {
|
} else if path.contains("get_versions") {
|
||||||
reply(200, json!({ "message": {
|
reply(
|
||||||
"frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" }
|
200,
|
||||||
}}))
|
json!({ "message": {
|
||||||
|
"frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" }
|
||||||
|
}}),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") {
|
} else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") {
|
||||||
reply(200, json!({ "data": { "gst_accounts": [] } }))
|
reply(200, json!({ "data": { "gst_accounts": [] } }))
|
||||||
} else if path.starts_with("/api/resource/Address?") {
|
} else if path.starts_with("/api/resource/Address?") {
|
||||||
reply(200, json!({ "data": [
|
reply(
|
||||||
{ "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" }
|
200,
|
||||||
]}))
|
json!({ "data": [
|
||||||
|
{ "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" }
|
||||||
|
]}),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/Company?") {
|
} else if path.starts_with("/api/resource/Company?") {
|
||||||
reply(200, json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }))
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/Account?") && path.contains("Income") {
|
} else if path.starts_with("/api/resource/Account?") && path.contains("Income") {
|
||||||
reply(200, json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] }))
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] }),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/Account?") && path.contains("Tax") {
|
} else if path.starts_with("/api/resource/Account?") && path.contains("Tax") {
|
||||||
reply(200, json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] }))
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] }),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/Price%20List?") {
|
} else if path.starts_with("/api/resource/Price%20List?") {
|
||||||
reply(403, json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" }))
|
reply(
|
||||||
|
403,
|
||||||
|
json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" }),
|
||||||
|
)
|
||||||
} else if path.contains("getdoctype") {
|
} else if path.contains("getdoctype") {
|
||||||
reply(200, json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] }))
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] }),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/UOM?") {
|
} else if path.starts_with("/api/resource/UOM?") {
|
||||||
reply(200, json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] }))
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] }),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
reply(200, json!({ "data": [] }))
|
reply(200, json!({ "data": [] }))
|
||||||
}
|
}
|
||||||
@@ -782,8 +1115,14 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn connection_test_reports_user_versions_ic_and_warnings() {
|
async fn connection_test_reports_user_versions_ic_and_warnings() {
|
||||||
let mock = serve_fn(|_, line| site(line)).await;
|
let mock = serve_fn(|_, line| site(line)).await;
|
||||||
let local = LocalFacts { vendor_registered: true, next_number: Some("INV/2026-001".into()), ..Default::default() };
|
let local = LocalFacts {
|
||||||
let result = test_connection(&client(&mock), &cfg(), &local).await.unwrap();
|
vendor_registered: true,
|
||||||
|
next_number: Some("INV/2026-001".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = test_connection(&client(&mock), &cfg(), &local)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(result.user, "integration@example.com");
|
assert_eq!(result.user, "integration@example.com");
|
||||||
assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0"));
|
assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0"));
|
||||||
assert!(result.india_compliance && result.features.v2_naming);
|
assert!(result.india_compliance && result.features.v2_naming);
|
||||||
@@ -810,17 +1149,25 @@ mod tests {
|
|||||||
let options = client(&mock).load_options("Test Co").await.unwrap();
|
let options = client(&mock).load_options("Test Co").await.unwrap();
|
||||||
assert_eq!(options.company_addresses.len(), 1);
|
assert_eq!(options.company_addresses.len(), 1);
|
||||||
assert_eq!(options.company_addresses[0].name, "Test Co-Billing");
|
assert_eq!(options.company_addresses[0].name, "Test Co-Billing");
|
||||||
assert!(options.errors.iter().all(|e| e.list != "companyAddresses"), "{:?}", options.errors);
|
assert!(
|
||||||
|
options.errors.iter().all(|e| e.list != "companyAddresses"),
|
||||||
|
"{:?}",
|
||||||
|
options.errors
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
|
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
|
||||||
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;
|
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;
|
||||||
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err();
|
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Auth);
|
assert_eq!(err.kind, ErrorKind::Auth);
|
||||||
|
|
||||||
let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await;
|
let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await;
|
||||||
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err();
|
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Auth);
|
assert_eq!(err.kind, ErrorKind::Auth);
|
||||||
assert!(!err.to_string().contains("thesecret"));
|
assert!(!err.to_string().contains("thesecret"));
|
||||||
}
|
}
|
||||||
@@ -837,17 +1184,29 @@ mod tests {
|
|||||||
assert_eq!(options.income_accounts[0].name, "Sales - AC");
|
assert_eq!(options.income_accounts[0].name, "Sales - AC");
|
||||||
assert_eq!(options.tax_accounts[0].name, "Output CGST - AC");
|
assert_eq!(options.tax_accounts[0].name, "Output CGST - AC");
|
||||||
assert_eq!(options.naming_series[0].name, "SINV-.YY.-");
|
assert_eq!(options.naming_series[0].name, "SINV-.YY.-");
|
||||||
assert_eq!(options.uoms.iter().map(|u| u.name.as_str()).collect::<Vec<_>>(), ["Nos", "Hour"]);
|
assert_eq!(
|
||||||
|
options
|
||||||
|
.uoms
|
||||||
|
.iter()
|
||||||
|
.map(|u| u.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["Nos", "Hour"]
|
||||||
|
);
|
||||||
assert!(options.price_lists.is_empty());
|
assert!(options.price_lists.is_empty());
|
||||||
assert_eq!(options.errors.len(), 1);
|
assert_eq!(options.errors.len(), 1);
|
||||||
assert_eq!(options.errors[0].list, "priceLists");
|
assert_eq!(options.errors[0].list, "priceLists");
|
||||||
assert!(options.errors[0].message.contains("No permission for Price List"));
|
assert!(options.errors[0]
|
||||||
|
.message
|
||||||
|
.contains("No permission for Price List"));
|
||||||
|
|
||||||
// Company-scoped lists are skipped until a company is chosen.
|
// Company-scoped lists are skipped until a company is chosen.
|
||||||
let none = client(&mock).load_options(" ").await.unwrap();
|
let none = client(&mock).load_options(" ").await.unwrap();
|
||||||
assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty());
|
assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty());
|
||||||
let requests = mock.requests.lock().unwrap().join("\n");
|
let requests = mock.requests.lock().unwrap().join("\n");
|
||||||
assert!(requests.contains("root_type"), "income accounts are filtered by root type");
|
assert!(
|
||||||
|
requests.contains("root_type"),
|
||||||
|
"income accounts are filtered by root type"
|
||||||
|
);
|
||||||
assert!(requests.contains("limit_page_length=0"));
|
assert!(requests.contains("limit_page_length=0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,6 +1215,10 @@ mod tests {
|
|||||||
let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await;
|
let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await;
|
||||||
let err = client(&mock).load_options("Test Co").await.unwrap_err();
|
let err = client(&mock).load_options("Test Co").await.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Auth);
|
assert_eq!(err.kind, ErrorKind::Auth);
|
||||||
assert_eq!(mock.hits.load(std::sync::atomic::Ordering::SeqCst), 1, "stops at the first auth failure");
|
assert_eq!(
|
||||||
|
mock.hits.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"stops at the first auth failure"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,12 @@ impl From<ErpError> for String {
|
|||||||
|
|
||||||
impl ErpError {
|
impl ErpError {
|
||||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||||
ErpError { kind, status: None, message: message.into(), exc_type: None }
|
ErpError {
|
||||||
|
kind,
|
||||||
|
status: None,
|
||||||
|
message: message.into(),
|
||||||
|
exc_type: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config(message: impl Into<String>) -> Self {
|
pub fn config(message: impl Into<String>) -> Self {
|
||||||
@@ -99,8 +104,15 @@ impl ErpError {
|
|||||||
if parsed.exc_type.as_deref() == Some("DuplicateEntryError") {
|
if parsed.exc_type.as_deref() == Some("DuplicateEntryError") {
|
||||||
kind = ErrorKind::Duplicate;
|
kind = ErrorKind::Duplicate;
|
||||||
}
|
}
|
||||||
let message = parsed.message.unwrap_or_else(|| default_status_text(status));
|
let message = parsed
|
||||||
ErpError { kind, status: Some(status), message, exc_type: parsed.exc_type }
|
.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.
|
/// Removes every occurrence of the given secrets from the message.
|
||||||
@@ -150,14 +162,24 @@ const MAX_MESSAGE_CHARS: usize = 600;
|
|||||||
pub fn parse_error_body(body: &str) -> ParsedError {
|
pub fn parse_error_body(body: &str) -> ParsedError {
|
||||||
let Ok(json) = serde_json::from_str::<Value>(body) else {
|
let Ok(json) = serde_json::from_str::<Value>(body) else {
|
||||||
let text = strip_html(body);
|
let text = strip_html(body);
|
||||||
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim();
|
let first = text
|
||||||
return ParsedError { message: non_empty(truncate(first)), exc_type: None };
|
.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 {
|
let Some(obj) = json.as_object() else {
|
||||||
return ParsedError::default();
|
return ParsedError::default();
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut exc_type = obj.get("exc_type").and_then(Value::as_str).map(str::to_string);
|
let mut exc_type = obj
|
||||||
|
.get("exc_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
let mut messages: Vec<String> = Vec::new();
|
let mut messages: Vec<String> = Vec::new();
|
||||||
|
|
||||||
if let Some(errors) = obj.get("errors").and_then(Value::as_array) {
|
if let Some(errors) = obj.get("errors").and_then(Value::as_array) {
|
||||||
@@ -165,8 +187,15 @@ pub fn parse_error_body(body: &str) -> ParsedError {
|
|||||||
match e {
|
match e {
|
||||||
Value::String(s) => push_unique(&mut messages, strip_html(s)),
|
Value::String(s) => push_unique(&mut messages, strip_html(s)),
|
||||||
Value::Object(o) => {
|
Value::Object(o) => {
|
||||||
let text = o.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty());
|
let text = o
|
||||||
let exception = o.get("exception").and_then(Value::as_str).map(split_exception);
|
.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() {
|
if exc_type.is_none() {
|
||||||
exc_type = o
|
exc_type = o
|
||||||
.get("type")
|
.get("type")
|
||||||
@@ -174,7 +203,9 @@ pub fn parse_error_body(body: &str) -> ParsedError {
|
|||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.or_else(|| exception.as_ref().and_then(|(t, _)| t.clone()));
|
.or_else(|| exception.as_ref().and_then(|(t, _)| t.clone()));
|
||||||
}
|
}
|
||||||
if let Some(m) = text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty())) {
|
if let Some(m) =
|
||||||
|
text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty()))
|
||||||
|
{
|
||||||
push_unique(&mut messages, m);
|
push_unique(&mut messages, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +222,12 @@ pub fn parse_error_body(body: &str) -> ParsedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
if let Some(m) = obj.get("_error_message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) {
|
if let Some(m) = obj
|
||||||
|
.get("_error_message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(strip_html)
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
{
|
||||||
messages.push(m);
|
messages.push(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +243,12 @@ pub fn parse_error_body(body: &str) -> ParsedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
if let Some(m) = obj.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) {
|
if let Some(m) = obj
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(strip_html)
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
{
|
||||||
messages.push(m);
|
messages.push(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,10 +259,16 @@ pub fn parse_error_body(body: &str) -> ParsedError {
|
|||||||
}
|
}
|
||||||
if exc_type.is_none() {
|
if exc_type.is_none() {
|
||||||
// v1 on Frappe 14 has no `exc_type`; the class is still named in `exception`.
|
// v1 on Frappe 14 has no `exc_type`; the class is still named in `exception`.
|
||||||
exc_type = obj.get("exception").and_then(Value::as_str).and_then(|e| split_exception(e).0);
|
exc_type = obj
|
||||||
|
.get("exception")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|e| split_exception(e).0);
|
||||||
}
|
}
|
||||||
|
|
||||||
ParsedError { message: non_empty(truncate(&messages.join("; "))), exc_type }
|
ParsedError {
|
||||||
|
message: non_empty(truncate(&messages.join("; "))),
|
||||||
|
exc_type,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn server_messages(raw: &str) -> Vec<String> {
|
fn server_messages(raw: &str) -> Vec<String> {
|
||||||
@@ -233,10 +280,18 @@ fn server_messages(raw: &str) -> Vec<String> {
|
|||||||
.filter_map(|item| {
|
.filter_map(|item| {
|
||||||
let text = match item {
|
let text = match item {
|
||||||
Value::String(s) => match serde_json::from_str::<Value>(s) {
|
Value::String(s) => match serde_json::from_str::<Value>(s) {
|
||||||
Ok(Value::Object(o)) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
|
Ok(Value::Object(o)) => o
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
_ => s.clone(),
|
_ => s.clone(),
|
||||||
},
|
},
|
||||||
Value::Object(o) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
|
Value::Object(o) => o
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
};
|
};
|
||||||
let text = strip_html(&text);
|
let text = strip_html(&text);
|
||||||
@@ -250,18 +305,28 @@ fn server_messages(raw: &str) -> Vec<String> {
|
|||||||
fn split_exception(raw: &str) -> (Option<String>, String) {
|
fn split_exception(raw: &str) -> (Option<String>, String) {
|
||||||
let first_line = raw.trim().lines().next().unwrap_or("").trim();
|
let first_line = raw.trim().lines().next().unwrap_or("").trim();
|
||||||
if let Some((prefix, rest)) = first_line.split_once(": ") {
|
if let Some((prefix, rest)) = first_line.split_once(": ") {
|
||||||
let class_like = prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception");
|
let class_like =
|
||||||
if class_like && prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') {
|
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);
|
let ty = prefix.rsplit('.').next().map(str::to_string);
|
||||||
return (ty, strip_html(rest));
|
return (ty, strip_html(rest));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !first_line.is_empty()
|
if !first_line.is_empty()
|
||||||
&& first_line.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
&& first_line
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||||
&& first_line.contains('.')
|
&& first_line.contains('.')
|
||||||
{
|
{
|
||||||
// `frappe.exceptions.DoesNotExistError` with no message.
|
// `frappe.exceptions.DoesNotExistError` with no message.
|
||||||
return (first_line.rsplit('.').next().map(str::to_string), String::new());
|
return (
|
||||||
|
first_line.rsplit('.').next().map(str::to_string),
|
||||||
|
String::new(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
(None, strip_html(first_line))
|
(None, strip_html(first_line))
|
||||||
}
|
}
|
||||||
@@ -297,8 +362,16 @@ pub fn strip_html(input: &str) -> String {
|
|||||||
}
|
}
|
||||||
'>' if in_tag => {
|
'>' if in_tag => {
|
||||||
in_tag = false;
|
in_tag = false;
|
||||||
let name = tag.trim_start_matches('/').split_whitespace().next().unwrap_or("").to_ascii_lowercase();
|
let name = tag
|
||||||
if matches!(name.as_str(), "br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3") {
|
.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');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +412,10 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let parsed = parse_error_body(&body);
|
let parsed = parse_error_body(&body);
|
||||||
assert_eq!(parsed.message.as_deref(), Some("Row 1: Account Sales & more; Second\nproblem"));
|
assert_eq!(
|
||||||
|
parsed.message.as_deref(),
|
||||||
|
Some("Row 1: Account Sales & more; Second\nproblem")
|
||||||
|
);
|
||||||
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
|
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
|
||||||
let err = ErpError::from_response(417, &body);
|
let err = ErpError::from_response(417, &body);
|
||||||
assert_eq!(err.kind, ErrorKind::Validation);
|
assert_eq!(err.kind, ErrorKind::Validation);
|
||||||
@@ -354,7 +430,10 @@ mod tests {
|
|||||||
let b = parse_error_body(
|
let b = parse_error_body(
|
||||||
&json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(),
|
&json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(),
|
||||||
);
|
);
|
||||||
assert_eq!(b.message.as_deref(), Some("Sales Invoice INV/1 already exists"));
|
assert_eq!(
|
||||||
|
b.message.as_deref(),
|
||||||
|
Some("Sales Invoice INV/1 already exists")
|
||||||
|
);
|
||||||
assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError"));
|
assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError"));
|
||||||
|
|
||||||
let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string());
|
let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string());
|
||||||
@@ -363,8 +442,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exception_prefix_is_only_dropped_when_it_looks_like_a_class_path() {
|
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());
|
let parsed = parse_error_body(
|
||||||
assert_eq!(parsed.message.as_deref(), Some("Something: with a colon but no class"));
|
&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);
|
assert_eq!(parsed.exc_type, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +462,10 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let parsed = parse_error_body(&body);
|
let parsed = parse_error_body(&body);
|
||||||
assert_eq!(parsed.message.as_deref(), Some("Customer X is disabled; Row 2: rate missing"));
|
assert_eq!(
|
||||||
|
parsed.message.as_deref(),
|
||||||
|
Some("Customer X is disabled; Row 2: rate missing")
|
||||||
|
);
|
||||||
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
|
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,22 +493,36 @@ mod tests {
|
|||||||
(502, ErrorKind::Server),
|
(502, ErrorKind::Server),
|
||||||
(418, ErrorKind::Other),
|
(418, ErrorKind::Other),
|
||||||
] {
|
] {
|
||||||
assert_eq!(ErpError::from_response(status, "").kind, kind, "status {status}");
|
assert_eq!(
|
||||||
|
ErpError::from_response(status, "").kind,
|
||||||
|
kind,
|
||||||
|
"status {status}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// The exception class wins when the status is generic.
|
// The exception class wins when the status is generic.
|
||||||
let dup = ErpError::from_response(417, &json!({ "exc_type": "DuplicateEntryError" }).to_string());
|
let dup = ErpError::from_response(
|
||||||
|
417,
|
||||||
|
&json!({ "exc_type": "DuplicateEntryError" }).to_string(),
|
||||||
|
);
|
||||||
assert_eq!(dup.kind, ErrorKind::Duplicate);
|
assert_eq!(dup.kind, ErrorKind::Duplicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn secrets_are_redacted_from_messages() {
|
fn secrets_are_redacted_from_messages() {
|
||||||
let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header").redacted(&["topsecret", "abc123"]);
|
let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header")
|
||||||
|
.redacted(&["topsecret", "abc123"]);
|
||||||
assert_eq!(err.message, "bad token [redacted]:[redacted] in header");
|
assert_eq!(err.message, "bad token [redacted]:[redacted] in header");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_carries_the_status_and_hint() {
|
fn display_carries_the_status_and_hint() {
|
||||||
let err = ErpError::from_response(401, &json!({ "message": "Invalid credentials" }).to_string());
|
let err = ErpError::from_response(
|
||||||
assert_eq!(err.to_string(), "Authentication failed (HTTP 401): Invalid credentials");
|
401,
|
||||||
|
&json!({ "message": "Invalid credentials" }).to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"Authentication failed (HTTP 401): Invalid credentials"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
|
|
||||||
use super::client::{ClientOptions, ErpClient};
|
use super::client::{ClientOptions, ErpClient};
|
||||||
use super::config::{self, ErpnextConfig, NamingMode, Secret};
|
use super::config::{self, ErpnextConfig, NamingMode, Secret};
|
||||||
use super::errors::ErrorKind;
|
|
||||||
use super::discovery::{test_connection, ConnectionTest, LocalFacts};
|
use super::discovery::{test_connection, ConnectionTest, LocalFacts};
|
||||||
|
use super::errors::ErrorKind;
|
||||||
use super::push::{push_invoice, push_payment, PushResult};
|
use super::push::{push_invoice, push_payment, PushResult};
|
||||||
use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl};
|
use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl};
|
||||||
use crate::commands::payments::{record_payment_impl, PaymentInput};
|
use crate::commands::payments::{record_payment_impl, PaymentInput};
|
||||||
@@ -78,7 +78,14 @@ impl Live {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
edit(&mut cfg);
|
edit(&mut cfg);
|
||||||
let http = ErpClient::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, "", ClientOptions::default()).unwrap();
|
let http = ErpClient::new(
|
||||||
|
&cfg.base_url,
|
||||||
|
&cfg.api_key,
|
||||||
|
&cfg.api_secret,
|
||||||
|
"",
|
||||||
|
ClientOptions::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -88,8 +95,24 @@ impl Live {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for (name, gstin, state, line1, city, pin, category) in [
|
for (name, gstin, state, line1, city, pin, category) in [
|
||||||
("Voiced Live Intra Co", "", "27", "5 FC Road", "Pune", "411004", "unregistered"),
|
(
|
||||||
("Voiced Live Inter Co", KA_GSTIN, "29", "12 MG Road", "Bengaluru", "560001", "registered_regular"),
|
"Voiced Live Intra Co",
|
||||||
|
"",
|
||||||
|
"27",
|
||||||
|
"5 FC Road",
|
||||||
|
"Pune",
|
||||||
|
"411004",
|
||||||
|
"unregistered",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Voiced Live Inter Co",
|
||||||
|
KA_GSTIN,
|
||||||
|
"29",
|
||||||
|
"12 MG Road",
|
||||||
|
"Bengaluru",
|
||||||
|
"560001",
|
||||||
|
"registered_regular",
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at)
|
"INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at)
|
||||||
@@ -98,12 +121,26 @@ impl Live {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() as u64;
|
let nanos = std::time::SystemTime::now()
|
||||||
let prefix = format!("L{}{}/", base36(nanos / 1_000_000, 4), base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1));
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos() as u64;
|
||||||
|
let prefix = format!(
|
||||||
|
"L{}{}/",
|
||||||
|
base36(nanos / 1_000_000, 4),
|
||||||
|
base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1)
|
||||||
|
);
|
||||||
start_new_series_impl(&mut conn, &prefix, 3).unwrap();
|
start_new_series_impl(&mut conn, &prefix, 3).unwrap();
|
||||||
config::save(&conn, &cfg).unwrap();
|
config::save(&conn, &cfg).unwrap();
|
||||||
|
|
||||||
let live = Live { db: Mutex::new(conn), dir: tempfile::tempdir().unwrap(), http, abbr, ic, prefix };
|
let live = Live {
|
||||||
|
db: Mutex::new(conn),
|
||||||
|
dir: tempfile::tempdir().unwrap(),
|
||||||
|
http,
|
||||||
|
abbr,
|
||||||
|
ic,
|
||||||
|
prefix,
|
||||||
|
};
|
||||||
// The same step the UI performs before the first push.
|
// The same step the UI performs before the first push.
|
||||||
let ct = live.connection_test(&cfg).await;
|
let ct = live.connection_test(&cfg).await;
|
||||||
let mut cfg = cfg;
|
let mut cfg = cfg;
|
||||||
@@ -113,8 +150,13 @@ impl Live {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest {
|
async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest {
|
||||||
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
let local = LocalFacts {
|
||||||
test_connection(&self.http, cfg, &local).await.expect("connection test")
|
vendor_registered: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
test_connection(&self.http, cfg, &local)
|
||||||
|
.await
|
||||||
|
.expect("connection test")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cfg(&self) -> ErpnextConfig {
|
fn cfg(&self) -> ErpnextConfig {
|
||||||
@@ -176,7 +218,10 @@ impl Live {
|
|||||||
self.db
|
self.db
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.execute("UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", params![sha, inv.id])
|
.execute(
|
||||||
|
"UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2",
|
||||||
|
params![sha, inv.id],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,13 +230,22 @@ impl Live {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn doc(&self, doctype: &str, name: &str) -> Value {
|
async fn doc(&self, doctype: &str, name: &str) -> Value {
|
||||||
let v = self.http.get(&["api", "resource", doctype, name], &[]).await.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}"));
|
let v = self
|
||||||
|
.http
|
||||||
|
.get(&["api", "resource", doctype, name], &[])
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}"));
|
||||||
v["data"].clone()
|
v["data"].clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn invoices_named(&self, number: &str) -> Vec<Value> {
|
async fn invoices_named(&self, number: &str) -> Vec<Value> {
|
||||||
self.http
|
self.http
|
||||||
.list_resource("Sales Invoice", &["name", "docstatus", "grand_total", "remarks"], json!([["remarks", "like", format!("%{number}%")]]), "creation asc")
|
.list_resource(
|
||||||
|
"Sales Invoice",
|
||||||
|
&["name", "docstatus", "grand_total", "remarks"],
|
||||||
|
json!([["remarks", "like", format!("%{number}%")]]),
|
||||||
|
"creation asc",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
@@ -201,7 +255,10 @@ impl Live {
|
|||||||
.list_resource(
|
.list_resource(
|
||||||
"File",
|
"File",
|
||||||
&["name", "file_name", "is_private", "file_url"],
|
&["name", "file_name", "is_private", "file_url"],
|
||||||
json!([["attached_to_doctype", "=", doctype], ["attached_to_name", "=", name]]),
|
json!([
|
||||||
|
["attached_to_doctype", "=", doctype],
|
||||||
|
["attached_to_name", "=", name]
|
||||||
|
]),
|
||||||
"creation asc",
|
"creation asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -248,7 +305,13 @@ fn minimal_pdf(label: &str) -> Vec<u8> {
|
|||||||
for o in offsets {
|
for o in offsets {
|
||||||
out.extend(format!("{o:010} 00000 n \n").into_bytes());
|
out.extend(format!("{o:010} 00000 n \n").into_bytes());
|
||||||
}
|
}
|
||||||
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes());
|
out.extend(
|
||||||
|
format!(
|
||||||
|
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
|
||||||
|
objects.len() + 1
|
||||||
|
)
|
||||||
|
.into_bytes(),
|
||||||
|
);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,8 +326,17 @@ fn assert_pushed(r: &PushResult) {
|
|||||||
|
|
||||||
/// The invoice as ERPNext holds it: total equals Voiced's, tax rows are what Voiced computed.
|
/// The invoice as ERPNext holds it: total equals Voiced's, tax rows are what Voiced computed.
|
||||||
async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
|
async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
|
||||||
assert_eq!(paise(&doc["grand_total"]), crate::gst::rupees_to_paise(inv.total), "grand_total of {}", inv.number);
|
assert_eq!(
|
||||||
assert_eq!(doc["rounded_total"].as_f64().unwrap_or(0.0), 0.0, "rounding is disabled");
|
paise(&doc["grand_total"]),
|
||||||
|
crate::gst::rupees_to_paise(inv.total),
|
||||||
|
"grand_total of {}",
|
||||||
|
inv.number
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
doc["rounded_total"].as_f64().unwrap_or(0.0),
|
||||||
|
0.0,
|
||||||
|
"rounding is disabled"
|
||||||
|
);
|
||||||
assert_eq!(doc["posting_date"], inv.invoice_date.as_str());
|
assert_eq!(doc["posting_date"], inv.invoice_date.as_str());
|
||||||
let taxes = doc["taxes"].as_array().unwrap();
|
let taxes = doc["taxes"].as_array().unwrap();
|
||||||
let by_head = |needle: &str| -> Option<i64> {
|
let by_head = |needle: &str| -> Option<i64> {
|
||||||
@@ -273,10 +345,25 @@ async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
|
|||||||
.find(|t| t["account_head"].as_str().unwrap_or("").contains(needle))
|
.find(|t| t["account_head"].as_str().unwrap_or("").contains(needle))
|
||||||
.map(|t| paise(&t["tax_amount"]))
|
.map(|t| paise(&t["tax_amount"]))
|
||||||
};
|
};
|
||||||
assert_eq!(by_head("CGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.cgst_amount).max(0), "CGST of {}", inv.number);
|
assert_eq!(
|
||||||
|
by_head("CGST").unwrap_or(0),
|
||||||
|
crate::gst::rupees_to_paise(inv.cgst_amount).max(0),
|
||||||
|
"CGST of {}",
|
||||||
|
inv.number
|
||||||
|
);
|
||||||
// IGST rows contain "IGST"; "CGST" and "SGST" rows do not.
|
// IGST rows contain "IGST"; "CGST" and "SGST" rows do not.
|
||||||
assert_eq!(by_head("SGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.sgst_amount), "SGST of {}", inv.number);
|
assert_eq!(
|
||||||
assert_eq!(by_head("IGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.igst_amount), "IGST of {}", inv.number);
|
by_head("SGST").unwrap_or(0),
|
||||||
|
crate::gst::rupees_to_paise(inv.sgst_amount),
|
||||||
|
"SGST of {}",
|
||||||
|
inv.number
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
by_head("IGST").unwrap_or(0),
|
||||||
|
crate::gst::rupees_to_paise(inv.igst_amount),
|
||||||
|
"IGST of {}",
|
||||||
|
inv.number
|
||||||
|
);
|
||||||
let _ = live;
|
let _ = live;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +377,10 @@ async fn live_connection_and_options() {
|
|||||||
let ct = live.connection_test(&cfg).await;
|
let ct = live.connection_test(&cfg).await;
|
||||||
println!("{}", serde_json::to_string_pretty(&ct).unwrap());
|
println!("{}", serde_json::to_string_pretty(&ct).unwrap());
|
||||||
assert!(ct.user.contains('@'));
|
assert!(ct.user.contains('@'));
|
||||||
assert_eq!(ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")), Some(true));
|
assert_eq!(
|
||||||
|
ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
assert!(ct.versions.erpnext.is_some());
|
assert!(ct.versions.erpnext.is_some());
|
||||||
assert_eq!(ct.india_compliance, live.ic);
|
assert_eq!(ct.india_compliance, live.ic);
|
||||||
assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming");
|
assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming");
|
||||||
@@ -298,14 +388,25 @@ async fn live_connection_and_options() {
|
|||||||
let o = live.http.load_options(&cfg.company).await.unwrap();
|
let o = live.http.load_options(&cfg.company).await.unwrap();
|
||||||
println!("option errors: {:?}", o.errors);
|
println!("option errors: {:?}", o.errors);
|
||||||
assert!(o.errors.is_empty(), "{:?}", o.errors);
|
assert!(o.errors.is_empty(), "{:?}", o.errors);
|
||||||
let has = |list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
|
let has =
|
||||||
|
|list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
|
||||||
assert!(has(&o.companies, &cfg.company));
|
assert!(has(&o.companies, &cfg.company));
|
||||||
assert!(has(&o.income_accounts, &cfg.income_account), "{:?}", o.income_accounts);
|
assert!(
|
||||||
assert!(o.tax_accounts.iter().any(|a| a.name.contains("CGST")), "{:?}", o.tax_accounts);
|
has(&o.income_accounts, &cfg.income_account),
|
||||||
|
"{:?}",
|
||||||
|
o.income_accounts
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
o.tax_accounts.iter().any(|a| a.name.contains("CGST")),
|
||||||
|
"{:?}",
|
||||||
|
o.tax_accounts
|
||||||
|
);
|
||||||
assert!(has(&o.uoms, "Hour") && has(&o.uoms, "Nos"));
|
assert!(has(&o.uoms, "Hour") && has(&o.uoms, "Nos"));
|
||||||
assert!(!o.company_addresses.is_empty());
|
assert!(!o.company_addresses.is_empty());
|
||||||
assert!(!o.naming_series.is_empty());
|
assert!(!o.naming_series.is_empty());
|
||||||
assert!(!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty());
|
assert!(
|
||||||
|
!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Sales Invoice mapping ----
|
// ---- Sales Invoice mapping ----
|
||||||
@@ -361,10 +462,16 @@ async fn live_push_matrix() {
|
|||||||
for (label, inv) in &cases {
|
for (label, inv) in &cases {
|
||||||
live.archive(inv);
|
live.archive(inv);
|
||||||
let r = live.push(inv, None).await;
|
let r = live.push(inv, None).await;
|
||||||
println!("{label}: {} -> {} (warnings {:?})", inv.number, r.remote_name, r.warnings);
|
println!(
|
||||||
|
"{label}: {} -> {} (warnings {:?})",
|
||||||
|
inv.number, r.remote_name, r.warnings
|
||||||
|
);
|
||||||
assert_pushed(&r);
|
assert_pushed(&r);
|
||||||
assert!(r.created && r.attached, "{label}: {r:?}");
|
assert!(r.created && r.attached, "{label}: {r:?}");
|
||||||
assert_eq!(r.remote_name, inv.number, "mirror naming keeps the Voiced number");
|
assert_eq!(
|
||||||
|
r.remote_name, inv.number,
|
||||||
|
"mirror naming keeps the Voiced number"
|
||||||
|
);
|
||||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
assert_eq!(doc["docstatus"], 0);
|
assert_eq!(doc["docstatus"], 0);
|
||||||
assert_matches(&live, inv, &doc).await;
|
assert_matches(&live, inv, &doc).await;
|
||||||
@@ -376,11 +483,18 @@ async fn live_push_matrix() {
|
|||||||
// Customer and address shape, as ERPNext stored them.
|
// Customer and address shape, as ERPNext stored them.
|
||||||
let inter = &cases[1].1;
|
let inter = &cases[1].1;
|
||||||
let doc = live.doc("Sales Invoice", &inter.number).await;
|
let doc = live.doc("Sales Invoice", &inter.number).await;
|
||||||
let customer = live.doc("Customer", doc["customer"].as_str().unwrap()).await;
|
let customer = live
|
||||||
|
.doc("Customer", doc["customer"].as_str().unwrap())
|
||||||
|
.await;
|
||||||
assert_eq!(customer["customer_name"], "Voiced Live Inter Co");
|
assert_eq!(customer["customer_name"], "Voiced Live Inter Co");
|
||||||
assert_eq!(customer["customer_type"], "Company");
|
assert_eq!(customer["customer_type"], "Company");
|
||||||
let address = live.doc("Address", doc["customer_address"].as_str().unwrap()).await;
|
let address = live
|
||||||
println!("address: {}", json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]}));
|
.doc("Address", doc["customer_address"].as_str().unwrap())
|
||||||
|
.await;
|
||||||
|
println!(
|
||||||
|
"address: {}",
|
||||||
|
json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]})
|
||||||
|
);
|
||||||
assert_eq!(address["state"], "Karnataka");
|
assert_eq!(address["state"], "Karnataka");
|
||||||
assert_eq!(address["country"], "India");
|
assert_eq!(address["country"], "India");
|
||||||
assert_eq!(address["address_type"], "Billing");
|
assert_eq!(address["address_type"], "Billing");
|
||||||
@@ -393,10 +507,17 @@ async fn live_push_matrix() {
|
|||||||
}
|
}
|
||||||
// A fresh local database (lost link) reuses the customer's address instead of creating another.
|
// A fresh local database (lost link) reuses the customer's address instead of creating another.
|
||||||
let again = Live::new(|_| {}).await;
|
let again = Live::new(|_| {}).await;
|
||||||
let inv = again.issue(false, json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
|
let inv = again.issue(
|
||||||
|
false,
|
||||||
|
json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
assert_pushed(&again.push(&inv, None).await);
|
assert_pushed(&again.push(&inv, None).await);
|
||||||
let d2 = again.doc("Sales Invoice", &inv.number).await;
|
let d2 = again.doc("Sales Invoice", &inv.number).await;
|
||||||
assert_eq!(d2["customer_address"], doc["customer_address"], "address reused");
|
assert_eq!(
|
||||||
|
d2["customer_address"], doc["customer_address"],
|
||||||
|
"address reused"
|
||||||
|
);
|
||||||
assert_eq!(d2["customer"], doc["customer"]);
|
assert_eq!(d2["customer"], doc["customer"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +529,13 @@ async fn live_half_paise_rounding() {
|
|||||||
// (taxable amount, discount): 9% of 10.50 is 0.945, 18% of 0.25 is 0.045 and so on. Voiced rounds half up.
|
// (taxable amount, discount): 9% of 10.50 is 0.945, 18% of 0.25 is 0.045 and so on. Voiced rounds half up.
|
||||||
// Half-even and half-up agree when the digit before the tie is odd (3.50 -> 0.315 -> 0.32), so only the
|
// Half-even and half-up agree when the digit before the tie is odd (3.50 -> 0.315 -> 0.32), so only the
|
||||||
// first two are certain to differ under Banker's Rounding.
|
// first two are certain to differ under Banker's Rounding.
|
||||||
let ties = [(10.5, 0.0, true), (7310.5, 0.0, true), (3.5, 0.0, false), (0.25, 0.0, false), (99.99, 0.15, false)];
|
let ties = [
|
||||||
|
(10.5, 0.0, true),
|
||||||
|
(7310.5, 0.0, true),
|
||||||
|
(3.5, 0.0, false),
|
||||||
|
(0.25, 0.0, false),
|
||||||
|
(99.99, 0.15, false),
|
||||||
|
];
|
||||||
for (amount, discount, tie) in ties {
|
for (amount, discount, tie) in ties {
|
||||||
let inv = live.issue(
|
let inv = live.issue(
|
||||||
true,
|
true,
|
||||||
@@ -416,16 +543,31 @@ async fn live_half_paise_rounding() {
|
|||||||
json!({"discount": discount}),
|
json!({"discount": discount}),
|
||||||
);
|
);
|
||||||
let r = live.push(&inv, Some(true)).await;
|
let r = live.push(&inv, Some(true)).await;
|
||||||
println!("{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}", r.ok, r.error);
|
println!(
|
||||||
|
"{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}",
|
||||||
|
r.ok, r.error
|
||||||
|
);
|
||||||
if tie && banker {
|
if tie && banker {
|
||||||
// ERPNext's Banker's Rounding disagrees by a paise: the draft is reported, kept, and never submitted.
|
// ERPNext's Banker's Rounding disagrees by a paise: the draft is reported, kept, and never submitted.
|
||||||
assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}");
|
assert!(
|
||||||
|
!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
let msg = r.error.as_deref().unwrap();
|
let msg = r.error.as_deref().unwrap();
|
||||||
assert!(msg.contains("Commercial Rounding") && msg.contains("does not submit"), "{msg}");
|
assert!(
|
||||||
|
msg.contains("Commercial Rounding") && msg.contains("does not submit"),
|
||||||
|
"{msg}"
|
||||||
|
);
|
||||||
assert_eq!(r.remote_name, inv.number);
|
assert_eq!(r.remote_name, inv.number);
|
||||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
assert_eq!(doc["docstatus"], 0, "a mismatching document is never submitted");
|
assert_eq!(
|
||||||
assert!(live.files_of("Sales Invoice", &r.remote_name).await.is_empty());
|
doc["docstatus"], 0,
|
||||||
|
"a mismatching document is never submitted"
|
||||||
|
);
|
||||||
|
assert!(live
|
||||||
|
.files_of("Sales Invoice", &r.remote_name)
|
||||||
|
.await
|
||||||
|
.is_empty());
|
||||||
// Pushing again finds the same draft and reports the same conflict instead of creating another.
|
// Pushing again finds the same draft and reports the same conflict instead of creating another.
|
||||||
let again = live.push(&inv, Some(true)).await;
|
let again = live.push(&inv, Some(true)).await;
|
||||||
assert!(!again.ok && again.status == "conflict", "{again:?}");
|
assert!(!again.ok && again.status == "conflict", "{again:?}");
|
||||||
@@ -442,7 +584,11 @@ async fn live_half_paise_rounding() {
|
|||||||
#[ignore = "needs the live ERPNext stack"]
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
async fn live_repush_is_idempotent() {
|
async fn live_repush_is_idempotent() {
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let inv = live.issue(true, json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]), json!({}));
|
let inv = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
live.archive(&inv);
|
live.archive(&inv);
|
||||||
let first = live.push(&inv, None).await;
|
let first = live.push(&inv, None).await;
|
||||||
assert_pushed(&first);
|
assert_pushed(&first);
|
||||||
@@ -452,7 +598,11 @@ async fn live_repush_is_idempotent() {
|
|||||||
assert_pushed(&again);
|
assert_pushed(&again);
|
||||||
assert!(again.no_op && !again.created, "{again:?}");
|
assert!(again.no_op && !again.created, "{again:?}");
|
||||||
// Lose the local sync state: the POST gets a 409 and the document is adopted, not duplicated.
|
// Lose the local sync state: the POST gets a 409 and the document is adopted, not duplicated.
|
||||||
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
|
live.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute("DELETE FROM erpnext_sync", [])
|
||||||
|
.unwrap();
|
||||||
let adopted = live.push(&inv, None).await;
|
let adopted = live.push(&inv, None).await;
|
||||||
assert_pushed(&adopted);
|
assert_pushed(&adopted);
|
||||||
assert!(adopted.attached, "{adopted:?}");
|
assert!(adopted.attached, "{adopted:?}");
|
||||||
@@ -460,7 +610,12 @@ async fn live_repush_is_idempotent() {
|
|||||||
assert_eq!(adopted.remote_name, first.remote_name);
|
assert_eq!(adopted.remote_name, first.remote_name);
|
||||||
let all = live
|
let all = live
|
||||||
.http
|
.http
|
||||||
.list_resource("Sales Invoice", &["name"], json!([["name", "=", inv.number.clone()]]), "name asc")
|
.list_resource(
|
||||||
|
"Sales Invoice",
|
||||||
|
&["name"],
|
||||||
|
json!([["name", "=", inv.number.clone()]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(all.len(), 1);
|
assert_eq!(all.len(), 1);
|
||||||
@@ -475,13 +630,24 @@ async fn live_repush_is_idempotent() {
|
|||||||
async fn live_submit_paths() {
|
async fn live_submit_paths() {
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
// Submit while creating.
|
// Submit while creating.
|
||||||
let a = live.issue(true, json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
|
let a = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
let r = live.push(&a, Some(true)).await;
|
let r = live.push(&a, Some(true)).await;
|
||||||
assert_pushed(&r);
|
assert_pushed(&r);
|
||||||
assert_eq!(r.remote_docstatus, 1);
|
assert_eq!(r.remote_docstatus, 1);
|
||||||
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
|
assert_eq!(
|
||||||
|
live.doc("Sales Invoice", &r.remote_name).await["docstatus"],
|
||||||
|
1
|
||||||
|
);
|
||||||
// Submit an existing draft in a later push (mirror: v2 method route).
|
// Submit an existing draft in a later push (mirror: v2 method route).
|
||||||
let b = live.issue(true, json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]), json!({}));
|
let b = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
assert_pushed(&live.push(&b, Some(false)).await);
|
assert_pushed(&live.push(&b, Some(false)).await);
|
||||||
let r = live.push(&b, Some(true)).await;
|
let r = live.push(&b, Some(true)).await;
|
||||||
assert_pushed(&r);
|
assert_pushed(&r);
|
||||||
@@ -494,20 +660,43 @@ async fn live_submit_paths() {
|
|||||||
async fn live_series_naming_mode() {
|
async fn live_series_naming_mode() {
|
||||||
let live = Live::new(|c| c.naming_mode = NamingMode::Series).await;
|
let live = Live::new(|c| c.naming_mode = NamingMode::Series).await;
|
||||||
let o = live.http.load_options(&live.cfg().company).await.unwrap();
|
let o = live.http.load_options(&live.cfg().company).await.unwrap();
|
||||||
let series = o.naming_series.first().expect("a naming series").name.clone();
|
let series = o
|
||||||
|
.naming_series
|
||||||
|
.first()
|
||||||
|
.expect("a naming series")
|
||||||
|
.name
|
||||||
|
.clone();
|
||||||
println!("series options: {:?}", o.naming_series);
|
println!("series options: {:?}", o.naming_series);
|
||||||
live.save_cfg(|c| c.naming_series = series.clone());
|
live.save_cfg(|c| c.naming_series = series.clone());
|
||||||
let inv = live.issue(true, json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]), json!({"notes": "note"}));
|
let inv = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]),
|
||||||
|
json!({"notes": "note"}),
|
||||||
|
);
|
||||||
live.archive(&inv);
|
live.archive(&inv);
|
||||||
let r = live.push(&inv, Some(false)).await;
|
let r = live.push(&inv, Some(false)).await;
|
||||||
assert_pushed(&r);
|
assert_pushed(&r);
|
||||||
assert!(r.created);
|
assert!(r.created);
|
||||||
assert_ne!(r.remote_name, inv.number, "series mode lets ERPNext name it");
|
assert_ne!(
|
||||||
|
r.remote_name, inv.number,
|
||||||
|
"series mode lets ERPNext name it"
|
||||||
|
);
|
||||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
assert!(doc["remarks"].as_str().unwrap().starts_with(&format!("Voiced invoice {}", inv.number)), "{}", doc["remarks"]);
|
assert!(
|
||||||
|
doc["remarks"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with(&format!("Voiced invoice {}", inv.number)),
|
||||||
|
"{}",
|
||||||
|
doc["remarks"]
|
||||||
|
);
|
||||||
assert_matches(&live, &inv, &doc).await;
|
assert_matches(&live, &inv, &doc).await;
|
||||||
// Lose the sync row: the remarks lookup finds the document instead of creating another.
|
// Lose the sync row: the remarks lookup finds the document instead of creating another.
|
||||||
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
|
live.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute("DELETE FROM erpnext_sync", [])
|
||||||
|
.unwrap();
|
||||||
let again = live.push(&inv, Some(false)).await;
|
let again = live.push(&inv, Some(false)).await;
|
||||||
assert_pushed(&again);
|
assert_pushed(&again);
|
||||||
assert!(!again.created, "{again:?}");
|
assert!(!again.created, "{again:?}");
|
||||||
@@ -517,7 +706,10 @@ async fn live_series_naming_mode() {
|
|||||||
let s = live.push(&inv, Some(true)).await;
|
let s = live.push(&inv, Some(true)).await;
|
||||||
assert_pushed(&s);
|
assert_pushed(&s);
|
||||||
assert_eq!(s.remote_docstatus, 1);
|
assert_eq!(s.remote_docstatus, 1);
|
||||||
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
|
assert_eq!(
|
||||||
|
live.doc("Sales Invoice", &r.remote_name).await["docstatus"],
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -529,18 +721,62 @@ async fn live_uom_and_quantities() {
|
|||||||
let row = |desc: &str, unit: &str, qty: f64| json!([{"description": desc, "mode": "rate", "rate": 100.0, "unit": unit, "quantity": qty, "hsnSac": "998314"}]);
|
let row = |desc: &str, unit: &str, qty: f64| json!([{"description": desc, "mode": "rate", "rate": 100.0, "unit": unit, "quantity": qty, "hsnSac": "998314"}]);
|
||||||
let mut outcomes = Vec::new();
|
let mut outcomes = Vec::new();
|
||||||
for (label, desc, unit, qty) in [
|
for (label, desc, unit, qty) in [
|
||||||
("Hour row on a Nos item, whole qty", "Nos preset", "hour", 2.0),
|
(
|
||||||
("Hour row on a Nos item, fractional qty", "Nos preset", "hour", 1.5),
|
"Hour row on a Nos item, whole qty",
|
||||||
("Hour row on an Hour item, fractional qty", "Hourly preset", "hour", 1.5),
|
"Nos preset",
|
||||||
("Nos row on an Hour item, whole qty", "Hourly preset", "unit", 3.0),
|
"hour",
|
||||||
("code-less hours, fractional qty", "Code-less hours", "hour", 1.5),
|
2.0,
|
||||||
("code-less minutes, fractional qty", "Code-less minutes", "minute", 90.5),
|
),
|
||||||
("code-less Nos, fractional qty", "Code-less nos", "unit", 1.5),
|
(
|
||||||
("code-less Nos, whole qty", "Code-less nos whole", "unit", 4.0),
|
"Hour row on a Nos item, fractional qty",
|
||||||
|
"Nos preset",
|
||||||
|
"hour",
|
||||||
|
1.5,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Hour row on an Hour item, fractional qty",
|
||||||
|
"Hourly preset",
|
||||||
|
"hour",
|
||||||
|
1.5,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Nos row on an Hour item, whole qty",
|
||||||
|
"Hourly preset",
|
||||||
|
"unit",
|
||||||
|
3.0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"code-less hours, fractional qty",
|
||||||
|
"Code-less hours",
|
||||||
|
"hour",
|
||||||
|
1.5,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"code-less minutes, fractional qty",
|
||||||
|
"Code-less minutes",
|
||||||
|
"minute",
|
||||||
|
90.5,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"code-less Nos, fractional qty",
|
||||||
|
"Code-less nos",
|
||||||
|
"unit",
|
||||||
|
1.5,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"code-less Nos, whole qty",
|
||||||
|
"Code-less nos whole",
|
||||||
|
"unit",
|
||||||
|
4.0,
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let inv = live.issue(true, row(desc, unit, qty), json!({}));
|
let inv = live.issue(true, row(desc, unit, qty), json!({}));
|
||||||
let r = live.push(&inv, None).await;
|
let r = live.push(&inv, None).await;
|
||||||
println!("{label}: ok={} {}", r.ok, r.error.clone().unwrap_or_default());
|
println!(
|
||||||
|
"{label}: ok={} {}",
|
||||||
|
r.ok,
|
||||||
|
r.error.clone().unwrap_or_default()
|
||||||
|
);
|
||||||
outcomes.push((label, r.ok));
|
outcomes.push((label, r.ok));
|
||||||
}
|
}
|
||||||
println!("{outcomes:?}");
|
println!("{outcomes:?}");
|
||||||
@@ -548,7 +784,10 @@ async fn live_uom_and_quantities() {
|
|||||||
assert!(ok("Hour row on an Hour item, fractional qty"));
|
assert!(ok("Hour row on an Hour item, fractional qty"));
|
||||||
assert!(ok("code-less hours, fractional qty") && ok("code-less minutes, fractional qty"));
|
assert!(ok("code-less hours, fractional qty") && ok("code-less minutes, fractional qty"));
|
||||||
assert!(ok("code-less Nos, whole qty"));
|
assert!(ok("code-less Nos, whole qty"));
|
||||||
assert!(!ok("code-less Nos, fractional qty"), "ERPNext refuses a fraction of a whole-number UOM");
|
assert!(
|
||||||
|
!ok("code-less Nos, fractional qty"),
|
||||||
|
"ERPNext refuses a fraction of a whole-number UOM"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- payments ----
|
// ---- payments ----
|
||||||
@@ -569,8 +808,32 @@ async fn live_payment_entry() {
|
|||||||
assert!(r.ok, "{:?}", r.error);
|
assert!(r.ok, "{:?}", r.error);
|
||||||
let entry = r.entry_name.clone().unwrap();
|
let entry = r.entry_name.clone().unwrap();
|
||||||
let pe = live.doc("Payment Entry", &entry).await;
|
let pe = live.doc("Payment Entry", &entry).await;
|
||||||
let keys = ["payment_type", "party_type", "paid_from", "paid_to", "paid_amount", "received_amount", "base_paid_amount", "base_received_amount", "total_allocated_amount", "unallocated_amount", "difference_amount", "reference_no", "reference_date", "posting_date", "bank_account", "mode_of_payment", "status"];
|
let keys = [
|
||||||
println!("PE {}", json!(keys.iter().map(|k| (k.to_string(), pe[*k].clone())).collect::<serde_json::Map<_, _>>()));
|
"payment_type",
|
||||||
|
"party_type",
|
||||||
|
"paid_from",
|
||||||
|
"paid_to",
|
||||||
|
"paid_amount",
|
||||||
|
"received_amount",
|
||||||
|
"base_paid_amount",
|
||||||
|
"base_received_amount",
|
||||||
|
"total_allocated_amount",
|
||||||
|
"unallocated_amount",
|
||||||
|
"difference_amount",
|
||||||
|
"reference_no",
|
||||||
|
"reference_date",
|
||||||
|
"posting_date",
|
||||||
|
"bank_account",
|
||||||
|
"mode_of_payment",
|
||||||
|
"status",
|
||||||
|
];
|
||||||
|
println!(
|
||||||
|
"PE {}",
|
||||||
|
json!(keys
|
||||||
|
.iter()
|
||||||
|
.map(|k| (k.to_string(), pe[*k].clone()))
|
||||||
|
.collect::<serde_json::Map<_, _>>())
|
||||||
|
);
|
||||||
println!("PE references {}", pe["references"].as_array().unwrap().iter().map(|r| json!({"ref": r["reference_name"], "allocated": r["allocated_amount"], "total": r["total_amount"], "outstanding": r["outstanding_amount"]}).to_string()).collect::<Vec<_>>().join(","));
|
println!("PE references {}", pe["references"].as_array().unwrap().iter().map(|r| json!({"ref": r["reference_name"], "allocated": r["allocated_amount"], "total": r["total_amount"], "outstanding": r["outstanding_amount"]}).to_string()).collect::<Vec<_>>().join(","));
|
||||||
println!("PE deductions {}", pe["deductions"].as_array().unwrap().iter().map(|r| json!({"account": r["account"], "cost_center": r["cost_center"], "amount": r["amount"], "description": r["description"]}).to_string()).collect::<Vec<_>>().join(","));
|
println!("PE deductions {}", pe["deductions"].as_array().unwrap().iter().map(|r| json!({"account": r["account"], "cost_center": r["cost_center"], "amount": r["amount"], "description": r["description"]}).to_string()).collect::<Vec<_>>().join(","));
|
||||||
assert_eq!(paise(&pe["paid_amount"]), cash);
|
assert_eq!(paise(&pe["paid_amount"]), cash);
|
||||||
@@ -580,14 +843,29 @@ async fn live_payment_entry() {
|
|||||||
let ded = pe["deductions"].as_array().unwrap();
|
let ded = pe["deductions"].as_array().unwrap();
|
||||||
assert_eq!(ded.len(), 1);
|
assert_eq!(ded.len(), 1);
|
||||||
assert_eq!(ded[0]["account"], live.cfg().tds_account.as_str());
|
assert_eq!(ded[0]["account"], live.cfg().tds_account.as_str());
|
||||||
assert_eq!(paise(&ded[0]["amount"]), tds, "a TDS deduction row is a positive amount");
|
assert_eq!(
|
||||||
|
paise(&ded[0]["amount"]),
|
||||||
|
tds,
|
||||||
|
"a TDS deduction row is a positive amount"
|
||||||
|
);
|
||||||
assert_eq!(pe["reference_no"], "UTR-LIVE-1");
|
assert_eq!(pe["reference_no"], "UTR-LIVE-1");
|
||||||
assert_eq!(pe["posting_date"], "2026-06-20");
|
assert_eq!(pe["posting_date"], "2026-06-20");
|
||||||
assert_eq!(pe["paid_to"], live.cfg().payment_bank_account.as_str(), "money lands in the configured bank account");
|
assert_eq!(
|
||||||
|
pe["paid_to"],
|
||||||
|
live.cfg().payment_bank_account.as_str(),
|
||||||
|
"money lands in the configured bank account"
|
||||||
|
);
|
||||||
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
|
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
|
||||||
println!("outstanding after payment: {}", inv_doc["outstanding_amount"]);
|
println!(
|
||||||
|
"outstanding after payment: {}",
|
||||||
|
inv_doc["outstanding_amount"]
|
||||||
|
);
|
||||||
assert_eq!(pe["docstatus"], 1);
|
assert_eq!(pe["docstatus"], 1);
|
||||||
assert_eq!(paise(&inv_doc["outstanding_amount"]), 0, "cash plus TDS settles the invoice");
|
assert_eq!(
|
||||||
|
paise(&inv_doc["outstanding_amount"]),
|
||||||
|
0,
|
||||||
|
"cash plus TDS settles the invoice"
|
||||||
|
);
|
||||||
// Idempotent locally.
|
// Idempotent locally.
|
||||||
let again = push_payment(&live.db, &live.http, pid).await;
|
let again = push_payment(&live.db, &live.http, pid).await;
|
||||||
assert!(again.ok && again.already_synced);
|
assert!(again.ok && again.already_synced);
|
||||||
@@ -597,14 +875,22 @@ async fn live_payment_entry() {
|
|||||||
#[ignore = "needs the live ERPNext stack"]
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
async fn live_payment_without_tds() {
|
async fn live_payment_without_tds() {
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let inv = live.issue(true, json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
let inv = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
assert_pushed(&live.push(&inv, Some(true)).await);
|
assert_pushed(&live.push(&inv, Some(true)).await);
|
||||||
let total = crate::gst::rupees_to_paise(inv.total);
|
let total = crate::gst::rupees_to_paise(inv.total);
|
||||||
let pid = live.payment(&inv, total / 2, 0);
|
let pid = live.payment(&inv, total / 2, 0);
|
||||||
let r = push_payment(&live.db, &live.http, pid).await;
|
let r = push_payment(&live.db, &live.http, pid).await;
|
||||||
assert!(r.ok, "{:?}", r.error);
|
assert!(r.ok, "{:?}", r.error);
|
||||||
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
|
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
|
||||||
assert_eq!(paise(&inv_doc["outstanding_amount"]), total - total / 2, "partial payment leaves the rest outstanding");
|
assert_eq!(
|
||||||
|
paise(&inv_doc["outstanding_amount"]),
|
||||||
|
total - total / 2,
|
||||||
|
"partial payment leaves the rest outstanding"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- India Compliance only (ERPNEXT_IC=1) ----
|
// ---- India Compliance only (ERPNEXT_IC=1) ----
|
||||||
@@ -625,10 +911,18 @@ async fn live_ic_hsn_enforcement() {
|
|||||||
}
|
}
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let mut outcomes = Vec::new();
|
let mut outcomes = Vec::new();
|
||||||
for (label, hsn) in [("no HSN", ""), ("4-digit HSN", "9983"), ("6-digit HSN", "998314"), ("8-digit HSN", "99831400")] {
|
for (label, hsn) in [
|
||||||
|
("no HSN", ""),
|
||||||
|
("4-digit HSN", "9983"),
|
||||||
|
("6-digit HSN", "998314"),
|
||||||
|
("8-digit HSN", "99831400"),
|
||||||
|
] {
|
||||||
let inv = live.issue(true, json!([{"description": format!("HSN {label}"), "mode": "fixed", "amount": 1000.0, "hsnSac": hsn}]), json!({}));
|
let inv = live.issue(true, json!([{"description": format!("HSN {label}"), "mode": "fixed", "amount": 1000.0, "hsnSac": hsn}]), json!({}));
|
||||||
let r = live.push(&inv, Some(true)).await;
|
let r = live.push(&inv, Some(true)).await;
|
||||||
println!("{label}: ok={} docstatus={} err={:?}", r.ok, r.remote_docstatus, r.error);
|
println!(
|
||||||
|
"{label}: ok={} docstatus={} err={:?}",
|
||||||
|
r.ok, r.remote_docstatus, r.error
|
||||||
|
);
|
||||||
outcomes.push((label, r));
|
outcomes.push((label, r));
|
||||||
}
|
}
|
||||||
let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
|
let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
|
||||||
@@ -653,7 +947,14 @@ async fn live_ic_number_longer_than_16_characters() {
|
|||||||
// Voiced never issues such a number; this is a number imported or migrated from elsewhere.
|
// Voiced never issues such a number; this is a number imported or migrated from elsewhere.
|
||||||
let long = format!("{}-EXTRA-LONG-PART", inv.number);
|
let long = format!("{}-EXTRA-LONG-PART", inv.number);
|
||||||
assert!(long.len() > 16);
|
assert!(long.len() > 16);
|
||||||
live.db.lock().unwrap().execute("UPDATE invoices SET number = ?1 WHERE id = ?2", params![long, inv.id]).unwrap();
|
live.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute(
|
||||||
|
"UPDATE invoices SET number = ?1 WHERE id = ?2",
|
||||||
|
params![long, inv.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let r = live.push(&inv, None).await;
|
let r = live.push(&inv, None).await;
|
||||||
println!("long number: ok={} err={:?}", r.ok, r.error);
|
println!("long number: ok={} err={:?}", r.ok, r.error);
|
||||||
assert!(!r.ok);
|
assert!(!r.ok);
|
||||||
@@ -668,26 +969,55 @@ async fn live_ic_gst_account_warnings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
let local = LocalFacts {
|
||||||
let codes = |ct: &ConnectionTest| ct.warnings.iter().map(|w| w.code.clone()).collect::<Vec<_>>();
|
vendor_registered: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let codes = |ct: &ConnectionTest| {
|
||||||
|
ct.warnings
|
||||||
|
.iter()
|
||||||
|
.map(|w| w.code.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
let clean = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
|
let clean = test_connection(&live.http, &live.cfg(), &local)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(clean.warnings.is_empty(), "{:?}", clean.warnings);
|
assert!(clean.warnings.is_empty(), "{:?}", clean.warnings);
|
||||||
|
|
||||||
let mut cfg = live.cfg();
|
let mut cfg = live.cfg();
|
||||||
cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr);
|
cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr);
|
||||||
let wrong = test_connection(&live.http, &cfg, &local).await.unwrap();
|
let wrong = test_connection(&live.http, &cfg, &local).await.unwrap();
|
||||||
assert_eq!(codes(&wrong), ["gst_account_mismatch"], "{:?}", wrong.warnings);
|
assert_eq!(
|
||||||
|
codes(&wrong),
|
||||||
|
["gst_account_mismatch"],
|
||||||
|
"{:?}",
|
||||||
|
wrong.warnings
|
||||||
|
);
|
||||||
|
|
||||||
let mut cfg = live.cfg();
|
let mut cfg = live.cfg();
|
||||||
cfg.company = "No Such Co".into();
|
cfg.company = "No Such Co".into();
|
||||||
let other = test_connection(&live.http, &cfg, &local).await.unwrap();
|
let other = test_connection(&live.http, &cfg, &local).await.unwrap();
|
||||||
assert!(codes(&other).contains(&"gst_accounts_not_configured".to_string()), "{:?}", other.warnings);
|
assert!(
|
||||||
|
codes(&other).contains(&"gst_accounts_not_configured".to_string()),
|
||||||
|
"{:?}",
|
||||||
|
other.warnings
|
||||||
|
);
|
||||||
|
|
||||||
// Mirror mode with a number the site would refuse.
|
// Mirror mode with a number the site would refuse.
|
||||||
let local = LocalFacts { vendor_registered: true, next_number: Some("TOOLONG-INVOICE-0001".into()), ..Default::default() };
|
let local = LocalFacts {
|
||||||
let ct = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
|
vendor_registered: true,
|
||||||
assert!(codes(&ct).contains(&"number_invalid".to_string()), "{:?}", ct.warnings);
|
next_number: Some("TOOLONG-INVOICE-0001".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let ct = test_connection(&live.http, &live.cfg(), &local)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
codes(&ct).contains(&"number_invalid".to_string()),
|
||||||
|
"{:?}",
|
||||||
|
ct.warnings
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -702,7 +1032,11 @@ async fn live_ic_reverse_charge_and_gst_fields() {
|
|||||||
// India Compliance refuses is_reverse_charge=1 without negative RCM tax rows ("Booked reverse charge is not
|
// India Compliance refuses is_reverse_charge=1 without negative RCM tax rows ("Booked reverse charge is not
|
||||||
// equal to applied tax amount"), whatever GST Settings say, so it is sent as 0 with a warning.
|
// equal to applied tax amount"), whatever GST Settings say, so it is sent as 0 with a warning.
|
||||||
assert_pushed(&r);
|
assert_pushed(&r);
|
||||||
assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings);
|
assert!(
|
||||||
|
r.warnings.iter().any(|w| w.contains("reverse charge")),
|
||||||
|
"{:?}",
|
||||||
|
r.warnings
|
||||||
|
);
|
||||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
assert_eq!(doc["is_reverse_charge"], 0);
|
assert_eq!(doc["is_reverse_charge"], 0);
|
||||||
assert_matches(&live, &inv, &doc).await;
|
assert_matches(&live, &inv, &doc).await;
|
||||||
@@ -718,13 +1052,34 @@ async fn live_ic_state_names_match_voiced() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let v = live.http.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Address".to_string())]).await.unwrap();
|
let v = live
|
||||||
|
.http
|
||||||
|
.get(
|
||||||
|
&["api", "method", "frappe.desk.form.load.getdoctype"],
|
||||||
|
&[("doctype", "Address".to_string())],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let docs = v["docs"].as_array().unwrap();
|
let docs = v["docs"].as_array().unwrap();
|
||||||
let address = docs.iter().find(|d| d["name"] == "Address").unwrap();
|
let address = docs.iter().find(|d| d["name"] == "Address").unwrap();
|
||||||
let options = address["fields"].as_array().unwrap().iter().find(|f| f["fieldname"] == "gst_state").expect("IC adds gst_state");
|
let options = address["fields"]
|
||||||
let ic: Vec<&str> = options["options"].as_str().unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).collect();
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|f| f["fieldname"] == "gst_state")
|
||||||
|
.expect("IC adds gst_state");
|
||||||
|
let ic: Vec<&str> = options["options"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect();
|
||||||
for (code, name) in crate::gst::STATES {
|
for (code, name) in crate::gst::STATES {
|
||||||
assert!(ic.contains(name), "India Compliance has no state named {name:?} ({code}); it has {ic:?}");
|
assert!(
|
||||||
|
ic.contains(name),
|
||||||
|
"India Compliance has no state named {name:?} ({code}); it has {ic:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}");
|
assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}");
|
||||||
}
|
}
|
||||||
@@ -733,23 +1088,40 @@ async fn live_ic_state_names_match_voiced() {
|
|||||||
#[ignore = "needs the live ERPNext stack"]
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
async fn live_payment_edge_cases() {
|
async fn live_payment_edge_cases() {
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let inv = live.issue(true, json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
let inv = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
// Not submitted yet: refused locally, nothing sent.
|
// Not submitted yet: refused locally, nothing sent.
|
||||||
assert_pushed(&live.push(&inv, Some(false)).await);
|
assert_pushed(&live.push(&inv, Some(false)).await);
|
||||||
let early = live.payment(&inv, 100_00, 0);
|
let early = live.payment(&inv, 100_00, 0);
|
||||||
let r = push_payment(&live.db, &live.http, early).await;
|
let r = push_payment(&live.db, &live.http, early).await;
|
||||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("not submitted"), "{r:?}");
|
assert!(
|
||||||
|
!r.ok && r.error.as_deref().unwrap().contains("not submitted"),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
assert_pushed(&live.push(&inv, Some(true)).await);
|
assert_pushed(&live.push(&inv, Some(true)).await);
|
||||||
let total = crate::gst::rupees_to_paise(inv.total);
|
let total = crate::gst::rupees_to_paise(inv.total);
|
||||||
// TDS only (no cash): settled by deduction alone.
|
// TDS only (no cash): settled by deduction alone.
|
||||||
let tds_only = live.payment(&inv, 0, total - 100_00);
|
let tds_only = live.payment(&inv, 0, total - 100_00);
|
||||||
let r = push_payment(&live.db, &live.http, tds_only).await;
|
let r = push_payment(&live.db, &live.http, tds_only).await;
|
||||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS only"), "{r:?}");
|
assert!(
|
||||||
|
!r.ok && r.error.as_deref().unwrap().contains("TDS only"),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
// Without a configured TDS account a TDS payment is refused locally.
|
// Without a configured TDS account a TDS payment is refused locally.
|
||||||
live.save_cfg(|c| c.tds_account = String::new());
|
live.save_cfg(|c| c.tds_account = String::new());
|
||||||
let inv2 = live.issue(true, json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
let inv2 = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
assert_pushed(&live.push(&inv2, Some(true)).await);
|
assert_pushed(&live.push(&inv2, Some(true)).await);
|
||||||
let t = live.payment(&inv2, 100_00, 50_00);
|
let t = live.payment(&inv2, 100_00, 50_00);
|
||||||
let r = push_payment(&live.db, &live.http, t).await;
|
let r = push_payment(&live.db, &live.http, t).await;
|
||||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS account"), "{r:?}");
|
assert!(
|
||||||
|
!r.ok && r.error.as_deref().unwrap().contains("TDS account"),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,12 @@ pub fn remarks_marker(number: &str) -> String {
|
|||||||
fn verified_totals(inv: &Invoice) -> Result<(TaxType, i64, Totals), String> {
|
fn verified_totals(inv: &Invoice) -> Result<(TaxType, i64, Totals), String> {
|
||||||
let tax_type = parse_tax_type(&inv.tax_type)?;
|
let tax_type = parse_tax_type(&inv.tax_type)?;
|
||||||
let rate_bp = (inv.tax_rate * 100.0).round() as i64;
|
let rate_bp = (inv.tax_rate * 100.0).round() as i64;
|
||||||
let totals = gst::compute_totals(&inv.items, gst::rupees_to_paise(inv.discount), tax_type, rate_bp);
|
let totals = gst::compute_totals(
|
||||||
|
&inv.items,
|
||||||
|
gst::rupees_to_paise(inv.discount),
|
||||||
|
tax_type,
|
||||||
|
rate_bp,
|
||||||
|
);
|
||||||
let stored = [
|
let stored = [
|
||||||
(totals.cgst, inv.cgst_amount, "CGST"),
|
(totals.cgst, inv.cgst_amount, "CGST"),
|
||||||
(totals.sgst, inv.sgst_amount, "SGST"),
|
(totals.sgst, inv.sgst_amount, "SGST"),
|
||||||
@@ -199,13 +204,21 @@ fn distinct_descriptions(items: &[InvoiceItem]) -> Vec<String> {
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, item)| {
|
.map(|(i, item)| {
|
||||||
let trimmed = item.description.trim();
|
let trimmed = item.description.trim();
|
||||||
let full = if trimmed.is_empty() { format!("Item {}", i + 1) } else { trimmed.to_string() };
|
let full = if trimmed.is_empty() {
|
||||||
|
format!("Item {}", i + 1)
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
};
|
||||||
let (head, tail) = first_line_and_rest(&full);
|
let (head, tail) = first_line_and_rest(&full);
|
||||||
let mut candidate = full.clone();
|
let mut candidate = full.clone();
|
||||||
let mut n = 2;
|
let mut n = 2;
|
||||||
while used.contains(&candidate.to_lowercase()) {
|
while used.contains(&candidate.to_lowercase()) {
|
||||||
let head = format!("{head} ({n})");
|
let head = format!("{head} ({n})");
|
||||||
candidate = if tail.is_empty() { head } else { format!("{head}\n{tail}") };
|
candidate = if tail.is_empty() {
|
||||||
|
head
|
||||||
|
} else {
|
||||||
|
format!("{head}\n{tail}")
|
||||||
|
};
|
||||||
n += 1;
|
n += 1;
|
||||||
}
|
}
|
||||||
used.insert(candidate.to_lowercase());
|
used.insert(candidate.to_lowercase());
|
||||||
@@ -271,7 +284,10 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let (head, _) = first_line_and_rest(&descriptions[i]);
|
let (head, _) = first_line_and_rest(&descriptions[i]);
|
||||||
row.insert("item_name".into(), json!(truncate_chars(head, ITEM_NAME_MAX)));
|
row.insert(
|
||||||
|
"item_name".into(),
|
||||||
|
json!(truncate_chars(head, ITEM_NAME_MAX)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row.insert("description".into(), json!(descriptions[i]));
|
row.insert("description".into(), json!(descriptions[i]));
|
||||||
@@ -296,7 +312,9 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
|
|||||||
|
|
||||||
fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
|
fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
|
||||||
if account.trim().is_empty() {
|
if account.trim().is_empty() {
|
||||||
return Err(format!("The {head} account is not set in the ERPNext settings."));
|
return Err(format!(
|
||||||
|
"The {head} account is not set in the ERPNext settings."
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"charge_type": "On Net Total",
|
"charge_type": "On Net Total",
|
||||||
@@ -306,10 +324,19 @@ fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state: &str) -> Result<Vec<Value>, String> {
|
fn tax_rows(
|
||||||
|
cfg: &ErpnextConfig,
|
||||||
|
tax_type: TaxType,
|
||||||
|
rate_bp: i64,
|
||||||
|
supplier_state: &str,
|
||||||
|
) -> Result<Vec<Value>, String> {
|
||||||
match tax_type {
|
match tax_type {
|
||||||
TaxType::None => Ok(Vec::new()),
|
TaxType::None => Ok(Vec::new()),
|
||||||
TaxType::Igst => Ok(vec![tax_row(&cfg.igst_account, "IGST", &scaled_decimal(rate_bp, 2))?]),
|
TaxType::Igst => Ok(vec![tax_row(
|
||||||
|
&cfg.igst_account,
|
||||||
|
"IGST",
|
||||||
|
&scaled_decimal(rate_bp, 2),
|
||||||
|
)?]),
|
||||||
TaxType::CgstSgst => {
|
TaxType::CgstSgst => {
|
||||||
// rate_bp is in hundredths of a percent; half of it in thousandths of a percent is rate_bp * 5.
|
// rate_bp is in hundredths of a percent; half of it in thousandths of a percent is rate_bp * 5.
|
||||||
let half = scaled_decimal(rate_bp * 5, 3);
|
let half = scaled_decimal(rate_bp * 5, 3);
|
||||||
@@ -319,7 +346,10 @@ fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state
|
|||||||
} else {
|
} else {
|
||||||
&cfg.sgst_account
|
&cfg.sgst_account
|
||||||
};
|
};
|
||||||
Ok(vec![tax_row(&cfg.cgst_account, "CGST", &half)?, tax_row(second_account, second, &half)?])
|
Ok(vec![
|
||||||
|
tax_row(&cfg.cgst_account, "CGST", &half)?,
|
||||||
|
tax_row(second_account, second, &half)?,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +358,10 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
|
|||||||
let inv = ctx.invoice;
|
let inv = ctx.invoice;
|
||||||
let cfg = ctx.config;
|
let cfg = ctx.config;
|
||||||
if inv.status != "issued" {
|
if inv.status != "issued" {
|
||||||
return Err(format!("Invoice {} is {}; only issued invoices are sent to ERPNext.", inv.number, inv.status));
|
return Err(format!(
|
||||||
|
"Invoice {} is {}; only issued invoices are sent to ERPNext.",
|
||||||
|
inv.number, inv.status
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if ctx.customer.trim().is_empty() {
|
if ctx.customer.trim().is_empty() {
|
||||||
return Err("The ERPNext customer is not resolved.".into());
|
return Err("The ERPNext customer is not resolved.".into());
|
||||||
@@ -346,7 +379,9 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
|
|||||||
}
|
}
|
||||||
NamingMode::Series => {
|
NamingMode::Series => {
|
||||||
if cfg.naming_series.trim().is_empty() {
|
if cfg.naming_series.trim().is_empty() {
|
||||||
return Err("ERPNext series naming is selected but no naming series is set.".into());
|
return Err(
|
||||||
|
"ERPNext series naming is selected but no naming series is set.".into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
body.insert("naming_series".into(), json!(cfg.naming_series.trim()));
|
body.insert("naming_series".into(), json!(cfg.naming_series.trim()));
|
||||||
}
|
}
|
||||||
@@ -396,14 +431,21 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
|
|||||||
body.insert("discount_amount".into(), money(totals.discount));
|
body.insert("discount_amount".into(), money(totals.discount));
|
||||||
}
|
}
|
||||||
body.insert("items".into(), Value::Array(item_rows(ctx, gst_fields)?));
|
body.insert("items".into(), Value::Array(item_rows(ctx, gst_fields)?));
|
||||||
body.insert("taxes".into(), Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?));
|
body.insert(
|
||||||
|
"taxes".into(),
|
||||||
|
Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?),
|
||||||
|
);
|
||||||
if ctx.submit {
|
if ctx.submit {
|
||||||
body.insert("docstatus".into(), json!(1));
|
body.insert("docstatus".into(), json!(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mirror = cfg.naming_mode == NamingMode::Mirror;
|
let mirror = cfg.naming_mode == NamingMode::Mirror;
|
||||||
Ok(BuiltRequest {
|
Ok(BuiltRequest {
|
||||||
path: if mirror { SALES_INVOICE_V2 } else { SALES_INVOICE_V1 },
|
path: if mirror {
|
||||||
|
SALES_INVOICE_V2
|
||||||
|
} else {
|
||||||
|
SALES_INVOICE_V1
|
||||||
|
},
|
||||||
body: Value::Object(body),
|
body: Value::Object(body),
|
||||||
idempotent: mirror,
|
idempotent: mirror,
|
||||||
})
|
})
|
||||||
@@ -416,7 +458,11 @@ fn has_gstin(client: &Client) -> bool {
|
|||||||
!g.is_empty() && !g.eq_ignore_ascii_case("NA")
|
!g.is_empty() && !g.eq_ignore_ascii_case("NA")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bool) -> Result<BuiltRequest, String> {
|
pub fn build_customer(
|
||||||
|
client: &Client,
|
||||||
|
cfg: &ErpnextConfig,
|
||||||
|
india_compliance: bool,
|
||||||
|
) -> Result<BuiltRequest, String> {
|
||||||
let name = client.name.trim();
|
let name = client.name.trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err("The client has no name.".into());
|
return Err("The client has no name.".into());
|
||||||
@@ -427,19 +473,33 @@ pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bo
|
|||||||
set_if(&mut body, "customer_group", &cfg.customer_group);
|
set_if(&mut body, "customer_group", &cfg.customer_group);
|
||||||
set_if(&mut body, "territory", &cfg.territory);
|
set_if(&mut body, "territory", &cfg.territory);
|
||||||
if india_compliance {
|
if india_compliance {
|
||||||
body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?));
|
body.insert(
|
||||||
let taxable_registration = matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez");
|
"gst_category".into(),
|
||||||
|
json!(gst_category_label(&client.gst_category)?),
|
||||||
|
);
|
||||||
|
let taxable_registration = matches!(
|
||||||
|
client.gst_category.as_str(),
|
||||||
|
"registered_regular" | "composition" | "sez"
|
||||||
|
);
|
||||||
if taxable_registration && has_gstin(client) {
|
if taxable_registration && has_gstin(client) {
|
||||||
let gstin = client.gstin.trim().to_ascii_uppercase();
|
let gstin = client.gstin.trim().to_ascii_uppercase();
|
||||||
gst::validate_gstin(&gstin).map_err(|e| format!("Client GSTIN: {e}"))?;
|
gst::validate_gstin(&gstin).map_err(|e| format!("Client GSTIN: {e}"))?;
|
||||||
body.insert("gstin".into(), json!(gstin));
|
body.insert("gstin".into(), json!(gstin));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(BuiltRequest { path: CUSTOMER, body: Value::Object(body), idempotent: false })
|
Ok(BuiltRequest {
|
||||||
|
path: CUSTOMER,
|
||||||
|
body: Value::Object(body),
|
||||||
|
idempotent: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `customer` is the name ERPNext returned when the Customer was created (a duplicate becomes "X - 1").
|
/// `customer` is the name ERPNext returned when the Customer was created (a duplicate becomes "X - 1").
|
||||||
pub fn build_address(client: &Client, customer: &str, india_compliance: bool) -> Result<BuiltRequest, String> {
|
pub fn build_address(
|
||||||
|
client: &Client,
|
||||||
|
customer: &str,
|
||||||
|
india_compliance: bool,
|
||||||
|
) -> Result<BuiltRequest, String> {
|
||||||
if customer.trim().is_empty() {
|
if customer.trim().is_empty() {
|
||||||
return Err("The ERPNext customer is not resolved.".into());
|
return Err("The ERPNext customer is not resolved.".into());
|
||||||
}
|
}
|
||||||
@@ -472,16 +532,31 @@ pub fn build_address(client: &Client, customer: &str, india_compliance: bool) ->
|
|||||||
set_if(&mut body, "pincode", &client.pincode);
|
set_if(&mut body, "pincode", &client.pincode);
|
||||||
body.insert("country".into(), json!("India"));
|
body.insert("country".into(), json!("India"));
|
||||||
if india_compliance {
|
if india_compliance {
|
||||||
body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?));
|
body.insert(
|
||||||
if has_gstin(client) && matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez") {
|
"gst_category".into(),
|
||||||
body.insert("gstin".into(), json!(client.gstin.trim().to_ascii_uppercase()));
|
json!(gst_category_label(&client.gst_category)?),
|
||||||
|
);
|
||||||
|
if has_gstin(client)
|
||||||
|
&& matches!(
|
||||||
|
client.gst_category.as_str(),
|
||||||
|
"registered_regular" | "composition" | "sez"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
body.insert(
|
||||||
|
"gstin".into(),
|
||||||
|
json!(client.gstin.trim().to_ascii_uppercase()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body.insert(
|
body.insert(
|
||||||
"links".into(),
|
"links".into(),
|
||||||
json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]),
|
json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]),
|
||||||
);
|
);
|
||||||
Ok(BuiltRequest { path: ADDRESS, body: Value::Object(body), idempotent: false })
|
Ok(BuiltRequest {
|
||||||
|
path: ADDRESS,
|
||||||
|
body: Value::Object(body),
|
||||||
|
idempotent: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -539,7 +614,13 @@ mod tests {
|
|||||||
Vendor::from_snapshot(&inv.vendor_snapshot).unwrap()
|
Vendor::from_snapshot(&inv.vendor_snapshot).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build(inv: &Invoice, cfg: &ErpnextConfig, ic: bool, codes: &[Option<String>], submit: bool) -> Result<BuiltRequest, String> {
|
fn build(
|
||||||
|
inv: &Invoice,
|
||||||
|
cfg: &ErpnextConfig,
|
||||||
|
ic: bool,
|
||||||
|
codes: &[Option<String>],
|
||||||
|
submit: bool,
|
||||||
|
) -> Result<BuiltRequest, String> {
|
||||||
let v = vendor(inv);
|
let v = vendor(inv);
|
||||||
build_sales_invoice(&InvoiceContext {
|
build_sales_invoice(&InvoiceContext {
|
||||||
invoice: inv,
|
invoice: inv,
|
||||||
@@ -597,7 +678,10 @@ mod tests {
|
|||||||
assert_eq!(scaled_decimal(1500, 3), "1.5");
|
assert_eq!(scaled_decimal(1500, 3), "1.5");
|
||||||
assert_eq!(scaled_decimal(2625, 3), "2.625");
|
assert_eq!(scaled_decimal(2625, 3), "2.625");
|
||||||
assert_eq!(money(731_005).to_string(), "7310.05");
|
assert_eq!(money(731_005).to_string(), "7310.05");
|
||||||
assert_eq!(money(i64::from(u32::MAX) * 100 + 7).to_string(), "4294967295.07");
|
assert_eq!(
|
||||||
|
money(i64::from(u32::MAX) * 100 + 7).to_string(),
|
||||||
|
"4294967295.07"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -607,7 +691,10 @@ mod tests {
|
|||||||
assert_eq!(place_of_supply_label("97").unwrap(), "97-Other Territory");
|
assert_eq!(place_of_supply_label("97").unwrap(), "97-Other Territory");
|
||||||
assert_eq!(place_of_supply_label("96").unwrap(), "96-Other Countries");
|
assert_eq!(place_of_supply_label("96").unwrap(), "96-Other Countries");
|
||||||
for (code, name) in gst::STATES {
|
for (code, name) in gst::STATES {
|
||||||
assert_eq!(place_of_supply_label(code).unwrap(), format!("{code}-{name}"));
|
assert_eq!(
|
||||||
|
place_of_supply_label(code).unwrap(),
|
||||||
|
format!("{code}-{name}")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert!(place_of_supply_label("28").is_err());
|
assert!(place_of_supply_label("28").is_err());
|
||||||
}
|
}
|
||||||
@@ -750,10 +837,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn plain_erpnext_registered_vendor_gets_tax_rows_but_no_india_compliance_fields() {
|
fn plain_erpnext_registered_vendor_gets_tax_rows_but_no_india_compliance_fields() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
let inv = issue(&mut conn, json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }));
|
let inv = issue(
|
||||||
|
&mut conn,
|
||||||
|
json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }),
|
||||||
|
);
|
||||||
let req = build(&inv, &cfg(), false, &[None], false).unwrap();
|
let req = build(&inv, &cfg(), false, &[None], false).unwrap();
|
||||||
assert_eq!(req.body["taxes"].as_array().unwrap().len(), 2);
|
assert_eq!(req.body["taxes"].as_array().unwrap().len(), 2);
|
||||||
assert!(req.body.get("place_of_supply").is_none() && req.body.get("is_reverse_charge").is_none());
|
assert!(
|
||||||
|
req.body.get("place_of_supply").is_none()
|
||||||
|
&& req.body.get("is_reverse_charge").is_none()
|
||||||
|
);
|
||||||
assert!(req.body["items"][0].get("gst_hsn_code").is_none());
|
assert!(req.body["items"][0].get("gst_hsn_code").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,8 +888,17 @@ mod tests {
|
|||||||
let req = build(&inv, &cfg(), false, &codes, false).unwrap();
|
let req = build(&inv, &cfg(), false, &codes, false).unwrap();
|
||||||
let rows = req.body["items"].as_array().unwrap();
|
let rows = req.body["items"].as_array().unwrap();
|
||||||
assert!(rows[0].get("stock_uom").is_none());
|
assert!(rows[0].get("stock_uom").is_none());
|
||||||
assert_eq!((rows[0]["uom"].as_str(), rows[0]["conversion_factor"].as_i64()), (Some("Hour"), Some(1)));
|
assert_eq!(
|
||||||
assert_eq!((rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), (Some("Minute"), Some("Minute")));
|
(
|
||||||
|
rows[0]["uom"].as_str(),
|
||||||
|
rows[0]["conversion_factor"].as_i64()
|
||||||
|
),
|
||||||
|
(Some("Hour"), Some(1))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()),
|
||||||
|
(Some("Minute"), Some("Minute"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -849,7 +951,10 @@ mod tests {
|
|||||||
assert!(!req.idempotent);
|
assert!(!req.idempotent);
|
||||||
assert!(req.body.get("name").is_none());
|
assert!(req.body.get("name").is_none());
|
||||||
assert_eq!(req.body["naming_series"], "SINV-.YY.-");
|
assert_eq!(req.body["naming_series"], "SINV-.YY.-");
|
||||||
assert_eq!(req.body["remarks"], format!("Voiced invoice {}\nNet 30", inv.number));
|
assert_eq!(
|
||||||
|
req.body["remarks"],
|
||||||
|
format!("Voiced invoice {}\nNet 30", inv.number)
|
||||||
|
);
|
||||||
|
|
||||||
c.naming_series.clear();
|
c.naming_series.clear();
|
||||||
assert!(build(&inv, &c, true, &[None], false).is_err());
|
assert!(build(&inv, &c, true, &[None], false).is_err());
|
||||||
@@ -859,8 +964,15 @@ mod tests {
|
|||||||
fn submit_adds_docstatus_and_drafts_do_not() {
|
fn submit_adds_docstatus_and_drafts_do_not() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
let inv = issue(&mut conn, json!({}));
|
let inv = issue(&mut conn, json!({}));
|
||||||
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("docstatus").is_none());
|
assert!(build(&inv, &cfg(), true, &[None], false)
|
||||||
assert_eq!(build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"], 1);
|
.unwrap()
|
||||||
|
.body
|
||||||
|
.get("docstatus")
|
||||||
|
.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"],
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -874,7 +986,9 @@ mod tests {
|
|||||||
|
|
||||||
// A supplier in a union territory without a legislature charges UTGST, not SGST.
|
// A supplier in a union territory without a legislature charges UTGST, not SGST.
|
||||||
let mut ut = inv.clone();
|
let mut ut = inv.clone();
|
||||||
ut.vendor_snapshot = ut.vendor_snapshot.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\"");
|
ut.vendor_snapshot = ut
|
||||||
|
.vendor_snapshot
|
||||||
|
.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\"");
|
||||||
let req = build(&ut, &cfg(), true, &[None], false).unwrap();
|
let req = build(&ut, &cfg(), true, &[None], false).unwrap();
|
||||||
assert_eq!(req.body["taxes"][1]["account_head"], "Output UTGST - AC");
|
assert_eq!(req.body["taxes"][1]["account_head"], "Output UTGST - AC");
|
||||||
assert_eq!(req.body["taxes"][1]["description"], "UTGST @ 2.625%");
|
assert_eq!(req.body["taxes"][1]["description"], "UTGST @ 2.625%");
|
||||||
@@ -901,11 +1015,17 @@ mod tests {
|
|||||||
let mut inv = issue(&mut conn, json!({}));
|
let mut inv = issue(&mut conn, json!({}));
|
||||||
let mut c = cfg();
|
let mut c = cfg();
|
||||||
c.cgst_account.clear();
|
c.cgst_account.clear();
|
||||||
assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("CGST account"));
|
assert!(build(&inv, &c, true, &[None], false)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("CGST account"));
|
||||||
c.company.clear();
|
c.company.clear();
|
||||||
assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("company"));
|
assert!(build(&inv, &c, true, &[None], false)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("company"));
|
||||||
inv.status = "cancelled".into();
|
inv.status = "cancelled".into();
|
||||||
assert!(build(&inv, &cfg(), true, &[None], false).unwrap_err().contains("only issued"));
|
assert!(build(&inv, &cfg(), true, &[None], false)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("only issued"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -913,9 +1033,17 @@ mod tests {
|
|||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
let mut inv = issue(&mut conn, json!({}));
|
let mut inv = issue(&mut conn, json!({}));
|
||||||
inv.due_date = "2026-03-01".into();
|
inv.due_date = "2026-03-01".into();
|
||||||
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none());
|
assert!(build(&inv, &cfg(), true, &[None], false)
|
||||||
|
.unwrap()
|
||||||
|
.body
|
||||||
|
.get("due_date")
|
||||||
|
.is_none());
|
||||||
inv.due_date.clear();
|
inv.due_date.clear();
|
||||||
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none());
|
assert!(build(&inv, &cfg(), true, &[None], false)
|
||||||
|
.unwrap()
|
||||||
|
.body
|
||||||
|
.get("due_date")
|
||||||
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -960,11 +1088,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let mut unreg = client();
|
let mut unreg = client();
|
||||||
unreg.gst_category = "unregistered".into();
|
unreg.gst_category = "unregistered".into();
|
||||||
assert!(build_customer(&unreg, &cfg(), true).unwrap().body.get("gstin").is_none());
|
assert!(build_customer(&unreg, &cfg(), true)
|
||||||
|
.unwrap()
|
||||||
|
.body
|
||||||
|
.get("gstin")
|
||||||
|
.is_none());
|
||||||
|
|
||||||
let mut bad = client();
|
let mut bad = client();
|
||||||
bad.gstin = "29AABCU9603R1Z0".into();
|
bad.gstin = "29AABCU9603R1Z0".into();
|
||||||
assert!(build_customer(&bad, &cfg(), true).unwrap_err().contains("GSTIN"));
|
assert!(build_customer(&bad, &cfg(), true)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("GSTIN"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -989,7 +1123,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let mut jk = client();
|
let mut jk = client();
|
||||||
jk.state_code = "01".into();
|
jk.state_code = "01".into();
|
||||||
assert_eq!(build_address(&jk, "X", true).unwrap().body["state"], "Jammu and Kashmir");
|
assert_eq!(
|
||||||
|
build_address(&jk, "X", true).unwrap().body["state"],
|
||||||
|
"Jammu and Kashmir"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -997,7 +1134,10 @@ mod tests {
|
|||||||
let mut c = client();
|
let mut c = client();
|
||||||
c.address_line1.clear();
|
c.address_line1.clear();
|
||||||
c.address = "5 Park Street\nKolkata, West Bengal - 700016".into();
|
c.address = "5 Park Street\nKolkata, West Bengal - 700016".into();
|
||||||
assert_eq!(build_address(&c, "X", false).unwrap().body["address_line1"], "5 Park Street");
|
assert_eq!(
|
||||||
|
build_address(&c, "X", false).unwrap().body["address_line1"],
|
||||||
|
"5 Park Street"
|
||||||
|
);
|
||||||
|
|
||||||
let mut no_city = client();
|
let mut no_city = client();
|
||||||
no_city.city.clear();
|
no_city.city.clear();
|
||||||
@@ -1005,7 +1145,11 @@ mod tests {
|
|||||||
let mut no_state = client();
|
let mut no_state = client();
|
||||||
no_state.state_code.clear();
|
no_state.state_code.clear();
|
||||||
assert!(build_address(&no_state, "X", true).is_err());
|
assert!(build_address(&no_state, "X", true).is_err());
|
||||||
assert!(build_address(&no_state, "X", false).unwrap().body.get("state").is_none());
|
assert!(build_address(&no_state, "X", false)
|
||||||
|
.unwrap()
|
||||||
|
.body
|
||||||
|
.get("state")
|
||||||
|
.is_none());
|
||||||
assert!(build_address(&client(), " ", true).is_err());
|
assert!(build_address(&client(), " ", true).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use super::config::{self, ErpnextConfig, NamingMode};
|
|||||||
use super::discovery::ic_number_ok;
|
use super::discovery::ic_number_ok;
|
||||||
use super::errors::{ErpError, ErrorKind};
|
use super::errors::{ErpError, ErrorKind};
|
||||||
use super::mapping::{
|
use super::mapping::{
|
||||||
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, InvoiceContext, Vendor,
|
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker,
|
||||||
SALES_INVOICE_V2,
|
InvoiceContext, Vendor, SALES_INVOICE_V2,
|
||||||
};
|
};
|
||||||
use crate::commands::archive::read_archive_impl;
|
use crate::commands::archive::read_archive_impl;
|
||||||
use crate::commands::invoice::get_invoice_impl;
|
use crate::commands::invoice::get_invoice_impl;
|
||||||
@@ -31,14 +31,20 @@ use std::sync::Mutex;
|
|||||||
type Db = Mutex<Connection>;
|
type Db = Mutex<Connection>;
|
||||||
|
|
||||||
const DOCTYPE_INVOICE: &str = "Sales Invoice";
|
const DOCTYPE_INVOICE: &str = "Sales Invoice";
|
||||||
const GET_PAYMENT_ENTRY: &str = "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
|
const GET_PAYMENT_ENTRY: &str =
|
||||||
|
"erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
|
||||||
|
|
||||||
fn pre(message: impl Into<String>) -> ErpError {
|
fn pre(message: impl Into<String>) -> ErpError {
|
||||||
ErpError::new(ErrorKind::Precondition, message)
|
ErpError::new(ErrorKind::Precondition, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_db<T>(db: &Db, f: impl FnOnce(&mut Connection) -> Result<T, String>) -> Result<T, ErpError> {
|
fn with_db<T>(
|
||||||
let mut conn = db.lock().map_err(|e| pre(format!("The database is busy: {e}")))?;
|
db: &Db,
|
||||||
|
f: impl FnOnce(&mut Connection) -> Result<T, String>,
|
||||||
|
) -> Result<T, ErpError> {
|
||||||
|
let mut conn = db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| pre(format!("The database is busy: {e}")))?;
|
||||||
f(&mut conn).map_err(|e| pre(format!("Could not read or save the sync state: {e}")))
|
f(&mut conn).map_err(|e| pre(format!("Could not read or save the sync state: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +203,11 @@ pub fn sync_statuses(conn: &Connection) -> Result<Vec<SyncStatus>, String> {
|
|||||||
|
|
||||||
/// `<base>/app/sales-invoice/<name>`, with the name URL-encoded (a mirrored number contains a slash).
|
/// `<base>/app/sales-invoice/<name>`, with the name URL-encoded (a mirrored number contains a slash).
|
||||||
pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
|
pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
|
||||||
const KEEP: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~');
|
const KEEP: &AsciiSet = &NON_ALPHANUMERIC
|
||||||
|
.remove(b'-')
|
||||||
|
.remove(b'_')
|
||||||
|
.remove(b'.')
|
||||||
|
.remove(b'~');
|
||||||
let row = load_sync(conn, invoice_id)?;
|
let row = load_sync(conn, invoice_id)?;
|
||||||
let name = row.map(|r| r.remote_name).unwrap_or_default();
|
let name = row.map(|r| r.remote_name).unwrap_or_default();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
@@ -205,7 +215,10 @@ pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
let cfg = config::load(conn)?;
|
let cfg = config::load(conn)?;
|
||||||
let base = super::client::normalize_base_url(&cfg.base_url).map_err(|e| e.to_string())?;
|
let base = super::client::normalize_base_url(&cfg.base_url).map_err(|e| e.to_string())?;
|
||||||
Ok(format!("{base}/app/sales-invoice/{}", utf8_percent_encode(&name, KEEP)))
|
Ok(format!(
|
||||||
|
"{base}/app/sales-invoice/{}",
|
||||||
|
utf8_percent_encode(&name, KEEP)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- loading ----
|
// ---- loading ----
|
||||||
@@ -294,7 +307,12 @@ fn load_client(conn: &Connection, inv: &Invoice) -> Result<ClientRow, String> {
|
|||||||
address_line2: String::new(),
|
address_line2: String::new(),
|
||||||
city: String::new(),
|
city: String::new(),
|
||||||
pincode: String::new(),
|
pincode: String::new(),
|
||||||
gst_category: if has_gstin { "registered_regular" } else { "unregistered" }.into(),
|
gst_category: if has_gstin {
|
||||||
|
"registered_regular"
|
||||||
|
} else {
|
||||||
|
"unregistered"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
default_notes: String::new(),
|
default_notes: String::new(),
|
||||||
payment_terms_days: None,
|
payment_terms_days: None,
|
||||||
invoice_count: 0,
|
invoice_count: 0,
|
||||||
@@ -321,7 +339,11 @@ fn load_item_codes(conn: &Connection, inv: &Invoice) -> Result<Vec<Option<String
|
|||||||
// ORDER BY id DESC: the oldest preset wins on a repeated description.
|
// ORDER BY id DESC: the oldest preset wins on a repeated description.
|
||||||
by_desc.insert(desc.trim().to_lowercase(), code.trim().to_string());
|
by_desc.insert(desc.trim().to_lowercase(), code.trim().to_string());
|
||||||
}
|
}
|
||||||
Ok(inv.items.iter().map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned()).collect())
|
Ok(inv
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned())
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when a connection test result is stored (blank, `null` and `{}` count as none).
|
/// True when a connection test result is stored (blank, `null` and `{}` count as none).
|
||||||
@@ -353,8 +375,11 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?;
|
let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?;
|
||||||
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot)
|
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot).ok_or_else(|| {
|
||||||
.ok_or_else(|| fail(pre(format!("Invoice {number} has no supplier details recorded, so it cannot be sent."))))?;
|
fail(pre(format!(
|
||||||
|
"Invoice {number} has no supplier details recorded, so it cannot be sent."
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
// A registered vendor's invoices go out with India Compliance fields, and those depend on what the
|
// A registered vendor's invoices go out with India Compliance fields, and those depend on what the
|
||||||
// connection test detected, so refuse until the test has run once.
|
// connection test detected, so refuse until the test has run once.
|
||||||
if vendor.registered && !has_detect_result(&cfg.last_detect_result) {
|
if vendor.registered && !has_detect_result(&cfg.last_detect_result) {
|
||||||
@@ -382,7 +407,17 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.get("indiaCompliance").and_then(Value::as_bool))
|
.and_then(|v| v.get("indiaCompliance").and_then(Value::as_bool))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
Ok(Loaded { cfg, invoice, vendor, client, item_codes, sync, pdf, pdf_warning, india_compliance })
|
Ok(Loaded {
|
||||||
|
cfg,
|
||||||
|
invoice,
|
||||||
|
vendor,
|
||||||
|
client,
|
||||||
|
item_codes,
|
||||||
|
sync,
|
||||||
|
pdf,
|
||||||
|
pdf_warning,
|
||||||
|
india_compliance,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- address checks (local, before anything is sent) ----
|
// ---- address checks (local, before anything is sent) ----
|
||||||
@@ -430,7 +465,10 @@ fn gstin_applies(client: &Client) -> bool {
|
|||||||
let g = client.gstin.trim();
|
let g = client.gstin.trim();
|
||||||
!g.is_empty()
|
!g.is_empty()
|
||||||
&& !g.eq_ignore_ascii_case("NA")
|
&& !g.eq_ignore_ascii_case("NA")
|
||||||
&& matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez")
|
&& matches!(
|
||||||
|
client.gst_category.as_str(),
|
||||||
|
"registered_regular" | "composition" | "sez"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The checks India Compliance would make on an Address, done here so the error is readable and local.
|
/// The checks India Compliance would make on an Address, done here so the error is readable and local.
|
||||||
@@ -440,7 +478,10 @@ pub fn validate_address(client: &Client) -> Result<(), String> {
|
|||||||
let pin = client.pincode.trim();
|
let pin = client.pincode.trim();
|
||||||
if !pin.is_empty() {
|
if !pin.is_empty() {
|
||||||
if pin.len() != 6 || !pin.bytes().all(|b| b.is_ascii_digit()) || pin.starts_with('0') {
|
if pin.len() != 6 || !pin.bytes().all(|b| b.is_ascii_digit()) || pin.starts_with('0') {
|
||||||
return Err(format!("{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.", client.name.trim()));
|
return Err(format!(
|
||||||
|
"{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.",
|
||||||
|
client.name.trim()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if let Some(allowed) = pin_prefixes(state_code) {
|
if let Some(allowed) = pin_prefixes(state_code) {
|
||||||
if !allowed.iter().any(|p| pin.starts_with(p)) {
|
if !allowed.iter().any(|p| pin.starts_with(p)) {
|
||||||
@@ -482,7 +523,9 @@ fn doc_docstatus(doc: &Value) -> i64 {
|
|||||||
|
|
||||||
fn doc_total_paise(doc: &Value) -> Option<i64> {
|
fn doc_total_paise(doc: &Value) -> Option<i64> {
|
||||||
let v = doc.get("grand_total")?;
|
let v = doc.get("grand_total")?;
|
||||||
let n = v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?;
|
let n = v
|
||||||
|
.as_f64()
|
||||||
|
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?;
|
||||||
Some(gst::rupees_to_paise(n))
|
Some(gst::rupees_to_paise(n))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,13 +545,26 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
|
|||||||
"creation asc",
|
"creation asc",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string);
|
found = rows
|
||||||
|
.first()
|
||||||
|
.and_then(|r| r.get("name"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
}
|
}
|
||||||
if found.is_none() {
|
if found.is_none() {
|
||||||
let rows = http
|
let rows = http
|
||||||
.list_resource("Customer", &["name"], json!([["customer_name", "=", name]]), "creation asc")
|
.list_resource(
|
||||||
|
"Customer",
|
||||||
|
&["name"],
|
||||||
|
json!([["customer_name", "=", name]]),
|
||||||
|
"creation asc",
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string);
|
found = rows
|
||||||
|
.first()
|
||||||
|
.and_then(|r| r.get("name"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
}
|
}
|
||||||
let customer = match found {
|
let customer = match found {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
@@ -521,14 +577,19 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
|
|||||||
let req = build_customer(client, &l.cfg, l.india_compliance).map_err(pre)?;
|
let req = build_customer(client, &l.cfg, l.india_compliance).map_err(pre)?;
|
||||||
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
||||||
// A duplicate name comes back as "X - 1": always use what the server returned.
|
// A duplicate name comes back as "X - 1": always use what the server returned.
|
||||||
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new customer's name."))?
|
doc_name(&resp).ok_or_else(|| {
|
||||||
|
ErpError::protocol("ERPNext did not return the new customer's name.")
|
||||||
|
})?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(id) = l.client.id {
|
if let Some(id) = l.client.id {
|
||||||
with_db(db, |c| {
|
with_db(db, |c| {
|
||||||
c.execute("UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2", params![customer, id])
|
c.execute(
|
||||||
.map(|_| ())
|
"UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2",
|
||||||
.map_err(|e| e.to_string())
|
params![customer, id],
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Ok(customer)
|
Ok(customer)
|
||||||
@@ -548,16 +609,27 @@ async fn ensure_address(
|
|||||||
let Some(client_id) = l.client.id else {
|
let Some(client_id) = l.client.id else {
|
||||||
return Ok((
|
return Ok((
|
||||||
None,
|
None,
|
||||||
Some("The client is not saved, so the invoice was sent without a customer address.".into()),
|
Some(
|
||||||
|
"The client is not saved, so the invoice was sent without a customer address."
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let has_address = [&client.address_line1, &client.address, &client.city, &client.pincode]
|
let has_address = [
|
||||||
.iter()
|
&client.address_line1,
|
||||||
.any(|s| !s.trim().is_empty());
|
&client.address,
|
||||||
|
&client.city,
|
||||||
|
&client.pincode,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|s| !s.trim().is_empty());
|
||||||
if !has_address {
|
if !has_address {
|
||||||
return Ok((
|
return Ok((
|
||||||
None,
|
None,
|
||||||
Some(format!("{} has no address saved, so the invoice was sent without a customer address.", client.name.trim())),
|
Some(format!(
|
||||||
|
"{} has no address saved, so the invoice was sent without a customer address.",
|
||||||
|
client.name.trim()
|
||||||
|
)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
validate_address(client).map_err(pre)?;
|
validate_address(client).map_err(pre)?;
|
||||||
@@ -568,13 +640,18 @@ async fn ensure_address(
|
|||||||
Some(existing) => existing,
|
Some(existing) => existing,
|
||||||
None => {
|
None => {
|
||||||
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
||||||
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?
|
doc_name(&resp).ok_or_else(|| {
|
||||||
|
ErpError::protocol("ERPNext did not return the new address's name.")
|
||||||
|
})?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
with_db(db, |c| {
|
with_db(db, |c| {
|
||||||
c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id])
|
c.execute(
|
||||||
.map(|_| ())
|
"UPDATE clients SET erpnext_address = ?1 WHERE id = ?2",
|
||||||
.map_err(|e| e.to_string())
|
params![name, client_id],
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
})?;
|
})?;
|
||||||
Ok((Some(name), None))
|
Ok((Some(name), None))
|
||||||
}
|
}
|
||||||
@@ -593,7 +670,15 @@ async fn find_address(http: &ErpClient, customer: &str, body: &Value) -> Option<
|
|||||||
if let Some(pin) = body.get("pincode").and_then(Value::as_str) {
|
if let Some(pin) = body.get("pincode").and_then(Value::as_str) {
|
||||||
filters.push(json!(["pincode", "=", pin]));
|
filters.push(json!(["pincode", "=", pin]));
|
||||||
}
|
}
|
||||||
let rows = http.list_resource("Address", &["name"], Value::Array(filters), "`tabAddress`.creation asc").await.ok()?;
|
let rows = http
|
||||||
|
.list_resource(
|
||||||
|
"Address",
|
||||||
|
&["name"],
|
||||||
|
Value::Array(filters),
|
||||||
|
"`tabAddress`.creation asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
rows.first()?.get("name")?.as_str().map(str::to_string)
|
rows.first()?.get("name")?.as_str().map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,7 +741,10 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result<Option<RemoteDo
|
|||||||
.list_resource(
|
.list_resource(
|
||||||
DOCTYPE_INVOICE,
|
DOCTYPE_INVOICE,
|
||||||
&["name", "docstatus", "grand_total", "remarks"],
|
&["name", "docstatus", "grand_total", "remarks"],
|
||||||
json!([["remarks", "like", format!("{marker}%")], ["docstatus", "!=", 2]]),
|
json!([
|
||||||
|
["remarks", "like", format!("{marker}%")],
|
||||||
|
["docstatus", "!=", 2]
|
||||||
|
]),
|
||||||
"creation asc",
|
"creation asc",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -674,7 +762,11 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result<Option<RemoteDo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltRequest) -> Result<RemoteDoc, ErpError> {
|
async fn create_or_find(
|
||||||
|
http: &ErpClient,
|
||||||
|
l: &Loaded,
|
||||||
|
body: &mapping::BuiltRequest,
|
||||||
|
) -> Result<RemoteDoc, ErpError> {
|
||||||
let inv = &l.invoice;
|
let inv = &l.invoice;
|
||||||
if l.cfg.naming_mode == NamingMode::Series {
|
if l.cfg.naming_mode == NamingMode::Series {
|
||||||
if let Some(found) = find_by_remarks(http, l).await? {
|
if let Some(found) = find_by_remarks(http, l).await? {
|
||||||
@@ -688,10 +780,17 @@ async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltReque
|
|||||||
(Some(n), _) => n,
|
(Some(n), _) => n,
|
||||||
(None, NamingMode::Mirror) => inv.number.clone(),
|
(None, NamingMode::Mirror) => inv.number.clone(),
|
||||||
(None, NamingMode::Series) => {
|
(None, NamingMode::Series) => {
|
||||||
return Err(ErpError::protocol("ERPNext did not return the new Sales Invoice's name."))
|
return Err(ErpError::protocol(
|
||||||
|
"ERPNext did not return the new Sales Invoice's name.",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true, total_paise: doc_total_paise(&doc) })
|
Ok(RemoteDoc {
|
||||||
|
name,
|
||||||
|
docstatus: doc_docstatus(&doc),
|
||||||
|
created: true,
|
||||||
|
total_paise: doc_total_paise(&doc),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => {
|
Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => {
|
||||||
// The mirrored name is taken: either a repeat of an earlier push or someone else's document.
|
// The mirrored name is taken: either a repeat of an earlier push or someone else's document.
|
||||||
@@ -719,11 +818,20 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res
|
|||||||
http.post(&path, &json!({}), false).await?
|
http.post(&path, &json!({}), false).await?
|
||||||
}
|
}
|
||||||
NamingMode::Series => {
|
NamingMode::Series => {
|
||||||
http.post(&["api", "resource", DOCTYPE_INVOICE, name], &json!({ "run_method": "submit" }), false).await?
|
http.post(
|
||||||
|
&["api", "resource", DOCTYPE_INVOICE, name],
|
||||||
|
&json!({ "run_method": "submit" }),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// A 2xx without a docstatus is taken as submitted; one that says otherwise is not.
|
// A 2xx without a docstatus is taken as submitted; one that says otherwise is not.
|
||||||
match resp.get("data").and_then(|d| d.get("docstatus")).and_then(Value::as_i64) {
|
match resp
|
||||||
|
.get("data")
|
||||||
|
.and_then(|d| d.get("docstatus"))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
{
|
||||||
Some(1) | None => Ok(1),
|
Some(1) | None => Ok(1),
|
||||||
Some(other) => Err(ErpError::protocol(format!(
|
Some(other) => Err(ErpError::protocol(format!(
|
||||||
"ERPNext accepted the submit request but the document is still at docstatus {other}."
|
"ERPNext accepted the submit request but the document is still at docstatus {other}."
|
||||||
@@ -734,15 +842,32 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res
|
|||||||
fn attachment_file_name(number: &str) -> String {
|
fn attachment_file_name(number: &str) -> String {
|
||||||
let cleaned: String = number
|
let cleaned: String = number
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' })
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let cleaned = cleaned.trim_matches('-');
|
let cleaned = cleaned.trim_matches('-');
|
||||||
format!("{}.pdf", if cleaned.is_empty() { "invoice" } else { cleaned })
|
format!(
|
||||||
|
"{}.pdf",
|
||||||
|
if cleaned.is_empty() {
|
||||||
|
"invoice"
|
||||||
|
} else {
|
||||||
|
cleaned
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the document already carries a file of this name. Used when the local row lost its attachment
|
/// True when the document already carries a file of this name. Used when the local row lost its attachment
|
||||||
/// hash (a re-adopted document), so the same PDF is not attached twice.
|
/// hash (a re-adopted document), so the same PDF is not attached twice.
|
||||||
async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) -> Result<bool, ErpError> {
|
async fn has_attachment(
|
||||||
|
http: &ErpClient,
|
||||||
|
remote_name: &str,
|
||||||
|
file_name: &str,
|
||||||
|
) -> Result<bool, ErpError> {
|
||||||
let rows = http
|
let rows = http
|
||||||
.list_resource(
|
.list_resource(
|
||||||
"File",
|
"File",
|
||||||
@@ -759,9 +884,19 @@ async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `adopted`: the document was found, not created by this push, and nothing is recorded as attached.
|
/// `adopted`: the document was found, not created by this push, and nothing is recorded as attached.
|
||||||
async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf, adopted: bool) -> Result<(), ErpError> {
|
async fn attach_pdf(
|
||||||
|
http: &ErpClient,
|
||||||
|
l: &Loaded,
|
||||||
|
remote_name: &str,
|
||||||
|
pdf: &Pdf,
|
||||||
|
adopted: bool,
|
||||||
|
) -> Result<(), ErpError> {
|
||||||
let file_name = attachment_file_name(&l.invoice.number);
|
let file_name = attachment_file_name(&l.invoice.number);
|
||||||
if adopted && has_attachment(http, remote_name, &file_name).await.unwrap_or(false) {
|
if adopted
|
||||||
|
&& has_attachment(http, remote_name, &file_name)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let fields = [
|
let fields = [
|
||||||
@@ -772,14 +907,24 @@ async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf,
|
|||||||
let resp = http
|
let resp = http
|
||||||
.post_file(
|
.post_file(
|
||||||
&["api", "method", "upload_file"],
|
&["api", "method", "upload_file"],
|
||||||
&Upload { file_name: &file_name, mime: "application/pdf", bytes: &pdf.bytes, fields: &fields },
|
&Upload {
|
||||||
|
file_name: &file_name,
|
||||||
|
mime: "application/pdf",
|
||||||
|
bytes: &pdf.bytes,
|
||||||
|
fields: &fields,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let ok = resp.get("message").map(|m| m.get("name").is_some() || m.get("file_url").is_some()).unwrap_or(false);
|
let ok = resp
|
||||||
|
.get("message")
|
||||||
|
.map(|m| m.get("name").is_some() || m.get("file_url").is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
if ok {
|
if ok {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(ErpError::protocol("ERPNext did not confirm the PDF upload."))
|
Err(ErpError::protocol(
|
||||||
|
"ERPNext did not confirm the PDF upload.",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +942,14 @@ struct Progress {
|
|||||||
warnings: Vec<String>,
|
warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, status: &str, error: &str) -> Result<(), ErpError> {
|
fn persist(
|
||||||
|
db: &Db,
|
||||||
|
invoice_id: i64,
|
||||||
|
prev: Option<&SyncRow>,
|
||||||
|
st: &Progress,
|
||||||
|
status: &str,
|
||||||
|
error: &str,
|
||||||
|
) -> Result<(), ErpError> {
|
||||||
let synced_at = if status == "synced" {
|
let synced_at = if status == "synced" {
|
||||||
Some(chrono::Utc::now().to_rfc3339())
|
Some(chrono::Utc::now().to_rfc3339())
|
||||||
} else {
|
} else {
|
||||||
@@ -818,7 +970,12 @@ fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, stat
|
|||||||
/// The message shown to the user: which step failed, then the error's own readable text. Local refusals and
|
/// The message shown to the user: which step failed, then the error's own readable text. Local refusals and
|
||||||
/// conflicts already say what is wrong and get no prefix.
|
/// conflicts already say what is wrong and get no prefix.
|
||||||
fn failure_text(step: &str, e: &ErpError) -> String {
|
fn failure_text(step: &str, e: &ErpError) -> String {
|
||||||
if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) {
|
if step.is_empty()
|
||||||
|
|| matches!(
|
||||||
|
e.kind,
|
||||||
|
ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict
|
||||||
|
)
|
||||||
|
{
|
||||||
e.to_string()
|
e.to_string()
|
||||||
} else if e.kind == ErrorKind::Validation && e.message.contains("cannot be a fraction") {
|
} else if e.kind == ErrorKind::Validation && e.message.contains("cannot be a fraction") {
|
||||||
// ERPNext names the row and the UOM already; say what to change in Voiced's settings.
|
// ERPNext names the row and the UOM already; say what to change in Voiced's settings.
|
||||||
@@ -828,7 +985,13 @@ fn failure_text(step: &str, e: &ErpError) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st: &mut Progress) -> Result<(), ErpError> {
|
async fn run_push(
|
||||||
|
db: &Db,
|
||||||
|
http: &ErpClient,
|
||||||
|
l: &Loaded,
|
||||||
|
want_submit: bool,
|
||||||
|
st: &mut Progress,
|
||||||
|
) -> Result<(), ErpError> {
|
||||||
let inv = &l.invoice;
|
let inv = &l.invoice;
|
||||||
let cfg = &l.cfg;
|
let cfg = &l.cfg;
|
||||||
let prev = l.sync.as_ref();
|
let prev = l.sync.as_ref();
|
||||||
@@ -882,7 +1045,10 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
st.payload_hash = hash;
|
st.payload_hash = hash;
|
||||||
if cfg.naming_mode == NamingMode::Mirror && l.india_compliance && !ic_number_ok(&inv.number) {
|
if cfg.naming_mode == NamingMode::Mirror
|
||||||
|
&& l.india_compliance
|
||||||
|
&& !ic_number_ok(&inv.number)
|
||||||
|
{
|
||||||
return Err(pre(format!(
|
return Err(pre(format!(
|
||||||
"Invoice number {} is longer than 16 characters or has characters India Compliance refuses (letters, digits, - and / only), so ERPNext would reject it. Use the ERPNext series naming mode, or start a new Voiced series with a shorter prefix.",
|
"Invoice number {} is longer than 16 characters or has characters India Compliance refuses (letters, digits, - and / only), so ERPNext would reject it. Use the ERPNext series naming mode, or start a new Voiced series with a shorter prefix.",
|
||||||
inv.number
|
inv.number
|
||||||
@@ -911,7 +1077,11 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
|||||||
if let (Some(w), None) = (&l.pdf_warning, &l.pdf) {
|
if let (Some(w), None) = (&l.pdf_warning, &l.pdf) {
|
||||||
st.warnings.push(w.clone());
|
st.warnings.push(w.clone());
|
||||||
}
|
}
|
||||||
if existing.is_some() && prev.is_some_and(|p| p.status == "synced") && !need_submit && pdf.is_none() {
|
if existing.is_some()
|
||||||
|
&& prev.is_some_and(|p| p.status == "synced")
|
||||||
|
&& !need_submit
|
||||||
|
&& pdf.is_none()
|
||||||
|
{
|
||||||
st.no_op = true;
|
st.no_op = true;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -933,7 +1103,13 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_id: i64, submit: Option<bool>) -> PushResult {
|
pub async fn push_invoice(
|
||||||
|
db: &Db,
|
||||||
|
local_dir: &Path,
|
||||||
|
http: &ErpClient,
|
||||||
|
invoice_id: i64,
|
||||||
|
submit: Option<bool>,
|
||||||
|
) -> PushResult {
|
||||||
let loaded = match load_for_push(db, local_dir, invoice_id) {
|
let loaded = match load_for_push(db, local_dir, invoice_id) {
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
Err((number, e)) => return PushResult::refused(invoice_id, &number, e),
|
Err((number, e)) => return PushResult::refused(invoice_id, &number, e),
|
||||||
@@ -946,7 +1122,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
|
|||||||
|
|
||||||
let (error, status): (Option<(ErpError, String)>, &str) = match outcome {
|
let (error, status): (Option<(ErpError, String)>, &str) = match outcome {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let write = if st.no_op { Ok(()) } else { persist(db, invoice_id, prev, &st, "synced", "") };
|
let write = if st.no_op {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
persist(db, invoice_id, prev, &st, "synced", "")
|
||||||
|
};
|
||||||
match write {
|
match write {
|
||||||
Ok(()) => (None, "synced"),
|
Ok(()) => (None, "synced"),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -957,7 +1137,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let text = failure_text(st.step, &e);
|
let text = failure_text(st.step, &e);
|
||||||
let status = if e.kind == ErrorKind::Conflict { "conflict" } else { "error" };
|
let status = if e.kind == ErrorKind::Conflict {
|
||||||
|
"conflict"
|
||||||
|
} else {
|
||||||
|
"error"
|
||||||
|
};
|
||||||
// Best effort: the original error is what the caller needs to see.
|
// Best effort: the original error is what the caller needs to see.
|
||||||
let _ = persist(db, invoice_id, prev, &st, status, &text);
|
let _ = persist(db, invoice_id, prev, &st, status, &text);
|
||||||
(Some((e, text)), status)
|
(Some((e, text)), status)
|
||||||
@@ -1022,23 +1206,44 @@ pub struct PaymentEntryInput<'a> {
|
|||||||
/// `difference_amount` is 0, `total_allocated_amount = cash + TDS` and the invoice's outstanding drops by
|
/// `difference_amount` is 0, `total_allocated_amount = cash + TDS` and the invoice's outstanding drops by
|
||||||
/// cash + TDS. A partial payment without TDS leaves the rest outstanding.
|
/// cash + TDS. A partial payment without TDS leaves the rest outstanding.
|
||||||
pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result<Value, String> {
|
pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result<Value, String> {
|
||||||
let mut doc: Map<String, Value> = draft.as_object().cloned().ok_or("ERPNext returned an unexpected payment draft.")?;
|
let mut doc: Map<String, Value> = draft
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.ok_or("ERPNext returned an unexpected payment draft.")?;
|
||||||
doc.retain(|k, _| !k.starts_with("__"));
|
doc.retain(|k, _| !k.starts_with("__"));
|
||||||
doc.insert("doctype".into(), json!("Payment Entry"));
|
doc.insert("doctype".into(), json!("Payment Entry"));
|
||||||
doc.insert("posting_date".into(), json!(p.paid_on));
|
doc.insert("posting_date".into(), json!(p.paid_on));
|
||||||
let reference = if p.reference.trim().is_empty() { format!("Voiced payment {}", p.payment_id) } else { p.reference.trim().to_string() };
|
let reference = if p.reference.trim().is_empty() {
|
||||||
|
format!("Voiced payment {}", p.payment_id)
|
||||||
|
} else {
|
||||||
|
p.reference.trim().to_string()
|
||||||
|
};
|
||||||
doc.insert("reference_no".into(), json!(reference));
|
doc.insert("reference_no".into(), json!(reference));
|
||||||
doc.insert("reference_date".into(), json!(p.paid_on));
|
doc.insert("reference_date".into(), json!(p.paid_on));
|
||||||
doc.insert("paid_amount".into(), mapping::money(p.cash_paise));
|
doc.insert("paid_amount".into(), mapping::money(p.cash_paise));
|
||||||
doc.insert("received_amount".into(), mapping::money(p.cash_paise));
|
doc.insert("received_amount".into(), mapping::money(p.cash_paise));
|
||||||
doc.insert("remarks".into(), json!(format!("Voiced payment {} for invoice {}", p.payment_id, p.invoice_number)));
|
doc.insert(
|
||||||
|
"remarks".into(),
|
||||||
|
json!(format!(
|
||||||
|
"Voiced payment {} for invoice {}",
|
||||||
|
p.payment_id, p.invoice_number
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
let allocated = mapping::money(p.cash_paise + p.tds_paise);
|
let allocated = mapping::money(p.cash_paise + p.tds_paise);
|
||||||
let refs = doc.get_mut("references").and_then(Value::as_array_mut).ok_or("ERPNext returned no invoice reference for this payment.")?;
|
let refs = doc
|
||||||
|
.get_mut("references")
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
.ok_or("ERPNext returned no invoice reference for this payment.")?;
|
||||||
let target = refs
|
let target = refs
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|r| r.get("reference_name").and_then(Value::as_str) == Some(p.remote_invoice))
|
.find(|r| r.get("reference_name").and_then(Value::as_str) == Some(p.remote_invoice))
|
||||||
.ok_or_else(|| format!("ERPNext's payment draft does not reference {}.", p.remote_invoice))?;
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"ERPNext's payment draft does not reference {}.",
|
||||||
|
p.remote_invoice
|
||||||
|
)
|
||||||
|
})?;
|
||||||
target["allocated_amount"] = allocated;
|
target["allocated_amount"] = allocated;
|
||||||
|
|
||||||
let deductions = if p.tds_paise > 0 {
|
let deductions = if p.tds_paise > 0 {
|
||||||
@@ -1100,7 +1305,11 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
|
|||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.ok_or_else(|| "Payment not found".to_string())?;
|
.ok_or_else(|| "Payment not found".to_string())?;
|
||||||
let number: String = conn
|
let number: String = conn
|
||||||
.query_row("SELECT number FROM invoices WHERE id = ?1", params![row.invoice_id], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT number FROM invoices WHERE id = ?1",
|
||||||
|
params![row.invoice_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let sync = load_sync(conn, row.invoice_id)?;
|
let sync = load_sync(conn, row.invoice_id)?;
|
||||||
let cfg = config::load(conn)?;
|
let cfg = config::load(conn)?;
|
||||||
@@ -1144,21 +1353,33 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if cfg.payment_bank_account.trim().is_empty() {
|
if cfg.payment_bank_account.trim().is_empty() {
|
||||||
return fail(pre("Set the payment bank account in the ERPNext settings first."));
|
return fail(pre(
|
||||||
|
"Set the payment bank account in the ERPNext settings first.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if row.tds_paise > 0 && cfg.tds_account.trim().is_empty() {
|
if row.tds_paise > 0 && cfg.tds_account.trim().is_empty() {
|
||||||
return fail(pre("This payment has TDS: set the TDS account in the ERPNext settings first."));
|
return fail(pre(
|
||||||
|
"This payment has TDS: set the TDS account in the ERPNext settings first.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = [
|
let query = [
|
||||||
("dt", DOCTYPE_INVOICE.to_string()),
|
("dt", DOCTYPE_INVOICE.to_string()),
|
||||||
("dn", remote_invoice.clone()),
|
("dn", remote_invoice.clone()),
|
||||||
("bank_account", cfg.payment_bank_account.trim().to_string()),
|
("bank_account", cfg.payment_bank_account.trim().to_string()),
|
||||||
("party_amount", paise_to_decimal(row.amount_paise + row.tds_paise)),
|
(
|
||||||
|
"party_amount",
|
||||||
|
paise_to_decimal(row.amount_paise + row.tds_paise),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
let draft = match http.get(&["api", "method", GET_PAYMENT_ENTRY], &query).await {
|
let draft = match http
|
||||||
|
.get(&["api", "method", GET_PAYMENT_ENTRY], &query)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(v) => v.get("message").cloned().unwrap_or(Value::Null),
|
Ok(v) => v.get("message").cloned().unwrap_or(Value::Null),
|
||||||
Err(e) => return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e),
|
Err(e) => {
|
||||||
|
return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let body = match build_payment_entry(
|
let body = match build_payment_entry(
|
||||||
&draft,
|
&draft,
|
||||||
@@ -1178,17 +1399,27 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
|
|||||||
Err(e) => return fail(ErpError::protocol(e)),
|
Err(e) => return fail(ErpError::protocol(e)),
|
||||||
};
|
};
|
||||||
// Not retried after a 5xx or timeout: it may have been created, and a second entry would double-count.
|
// Not retried after a 5xx or timeout: it may have been created, and a second entry would double-count.
|
||||||
let resp = match http.post(&["api", "resource", "Payment Entry"], &body, false).await {
|
let resp = match http
|
||||||
|
.post(&["api", "resource", "Payment Entry"], &body, false)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e),
|
Err(e) => {
|
||||||
|
return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let Some(name) = doc_name(&resp) else {
|
let Some(name) = doc_name(&resp) else {
|
||||||
return fail(ErpError::protocol("ERPNext did not return the new Payment Entry's name."));
|
return fail(ErpError::protocol(
|
||||||
|
"ERPNext did not return the new Payment Entry's name.",
|
||||||
|
));
|
||||||
};
|
};
|
||||||
if let Err(e) = with_db(db, |c| {
|
if let Err(e) = with_db(db, |c| {
|
||||||
c.execute("UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2", params![name, payment_id])
|
c.execute(
|
||||||
.map(|_| ())
|
"UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2",
|
||||||
.map_err(|e| e.to_string())
|
params![name, payment_id],
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
}) {
|
}) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -1223,7 +1454,14 @@ impl InvoiceSink for ErpnextSink<'_> {
|
|||||||
request: PushRequest,
|
request: PushRequest,
|
||||||
) -> impl std::future::Future<Output = Result<PushedInvoice, String>> + Send + 'a {
|
) -> impl std::future::Future<Output = Result<PushedInvoice, String>> + Send + 'a {
|
||||||
async move {
|
async move {
|
||||||
let r = push_invoice(self.db, self.local_dir, self.http, request.invoice_id, request.submit).await;
|
let r = push_invoice(
|
||||||
|
self.db,
|
||||||
|
self.local_dir,
|
||||||
|
self.http,
|
||||||
|
request.invoice_id,
|
||||||
|
request.submit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
if r.ok {
|
if r.ok {
|
||||||
Ok(PushedInvoice {
|
Ok(PushedInvoice {
|
||||||
remote_name: r.remote_name,
|
remote_name: r.remote_name,
|
||||||
@@ -1244,7 +1482,10 @@ impl InvoiceSink for ErpnextSink<'_> {
|
|||||||
async move {
|
async move {
|
||||||
let r = push_payment(self.db, self.http, request.payment_id).await;
|
let r = push_payment(self.db, self.http, request.payment_id).await;
|
||||||
match (r.ok, r.entry_name) {
|
match (r.ok, r.entry_name) {
|
||||||
(true, Some(name)) => Ok(PushedPayment { remote_name: name, created: !r.already_synced }),
|
(true, Some(name)) => Ok(PushedPayment {
|
||||||
|
remote_name: name,
|
||||||
|
created: !r.already_synced,
|
||||||
|
}),
|
||||||
_ => Err(r.error.unwrap_or_else(|| "The payment push failed.".into())),
|
_ => Err(r.error.unwrap_or_else(|| "The payment push failed.".into())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ pub struct Reply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn reply(status: u16, body: Value) -> Reply {
|
pub fn reply(status: u16, body: Value) -> Reply {
|
||||||
Reply { status, headers: vec![], body: body.to_string(), delay_ms: 0 }
|
Reply {
|
||||||
|
status,
|
||||||
|
headers: vec![],
|
||||||
|
body: body.to_string(),
|
||||||
|
delay_ms: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Mock {
|
pub struct Mock {
|
||||||
@@ -33,7 +38,12 @@ pub async fn serve(script: Vec<Reply>) -> Mock {
|
|||||||
let script = Arc::new(script);
|
let script = Arc::new(script);
|
||||||
serve_fn(move |i, _| {
|
serve_fn(move |i, _| {
|
||||||
let r = &script[i.min(script.len() - 1)];
|
let r = &script[i.min(script.len() - 1)];
|
||||||
Reply { status: r.status, headers: r.headers.clone(), body: r.body.clone(), delay_ms: r.delay_ms }
|
Reply {
|
||||||
|
status: r.status,
|
||||||
|
headers: r.headers.clone(),
|
||||||
|
body: r.body.clone(),
|
||||||
|
delay_ms: r.delay_ms,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -48,7 +58,9 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st
|
|||||||
let handler = Arc::new(handler);
|
let handler = Arc::new(handler);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
let Ok((mut sock, _)) = listener.accept().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let (h, r, handler) = (h.clone(), r.clone(), handler.clone());
|
let (h, r, handler) = (h.clone(), r.clone(), handler.clone());
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
@@ -97,7 +109,11 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Mock { base: format!("http://127.0.0.1:{port}"), hits, requests }
|
Mock {
|
||||||
|
base: format!("http://127.0.0.1:{port}"),
|
||||||
|
hits,
|
||||||
|
requests,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short timeouts and near-zero backoff so retry tests run in milliseconds.
|
/// Short timeouts and near-zero backoff so retry tests run in milliseconds.
|
||||||
@@ -113,5 +129,12 @@ pub fn fast_opts() -> ClientOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn client(mock: &Mock) -> ErpClient {
|
pub fn client(mock: &Mock) -> ErpClient {
|
||||||
ErpClient::new(&mock.base, "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap()
|
ErpClient::new(
|
||||||
|
&mock.base,
|
||||||
|
"thekey",
|
||||||
|
&Secret::new("thesecret"),
|
||||||
|
"",
|
||||||
|
fast_opts(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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};
|
||||||
@@ -38,23 +39,39 @@ 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.
|
// A restore staged by the Data tab is swapped in now, before anything opens the database.
|
||||||
@@ -62,19 +79,32 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
||||||
commands::backup::ApplyOutcome::NothingPending => {}
|
commands::backup::ApplyOutcome::NothingPending => {}
|
||||||
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
||||||
eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
|
eprintln!(
|
||||||
|
"Backup restored; the previous data is in {}",
|
||||||
|
safety_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
commands::backup::ApplyOutcome::Failed { message } => {
|
||||||
|
eprintln!("Backup restore failed: {message}")
|
||||||
}
|
}
|
||||||
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
|
|
||||||
}
|
}
|
||||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
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.
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "1.0.0",
|
"version": "1.1.0",
|
||||||
"identifier": "com.bongbetic.voiced",
|
"identifier": "com.bongbetic.voiced",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Clients from "./views/Clients";
|
|||||||
import { ToastProvider, useToast } from "./components/ToastProvider";
|
import { ToastProvider, useToast } from "./components/ToastProvider";
|
||||||
import AppSettings from "./views/AppSettings";
|
import AppSettings from "./views/AppSettings";
|
||||||
import { requestSettingsTab } from "./lib/settingsTab";
|
import { requestSettingsTab } from "./lib/settingsTab";
|
||||||
|
import { applyDocumentTheme, cachedTheme, normalizeTheme } from "./lib/theme";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -46,9 +47,15 @@ function AppInner() {
|
|||||||
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>
|
||||||
@@ -58,7 +65,7 @@ function AppInner() {
|
|||||||
|
|
||||||
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"
|
||||||
@@ -74,11 +81,9 @@ function AppInner() {
|
|||||||
|
|
||||||
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) => {
|
||||||
@@ -106,8 +111,8 @@ function AppInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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}
|
||||||
@@ -116,6 +121,8 @@ function AppInner() {
|
|||||||
onActivate={() => setView("new")}
|
onActivate={() => setView("new")}
|
||||||
openDraftId={openDraftId}
|
openDraftId={openDraftId}
|
||||||
onDraftOpened={() => setOpenDraftId(null)}
|
onDraftOpened={() => setOpenDraftId(null)}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
/>
|
/>
|
||||||
<div hidden={view !== "invoices"}>
|
<div hidden={view !== "invoices"}>
|
||||||
<InvoiceHistory
|
<InvoiceHistory
|
||||||
|
|||||||
@@ -61,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
|
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
|
||||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import type { Settings } from "../lib/types";
|
||||||
import {
|
import {
|
||||||
backupFileName,
|
backupFileName,
|
||||||
ensureZipExtension,
|
ensureZipExtension,
|
||||||
@@ -20,8 +21,15 @@ type RestoreStep = "confirm" | "staging" | "ready";
|
|||||||
const joinPath = (dir: string, name: string) =>
|
const joinPath = (dir: string, name: string) =>
|
||||||
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
||||||
|
|
||||||
/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */
|
interface DataBackupPanelProps {
|
||||||
export default function DataBackupPanel() {
|
/** 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 toast = useToast();
|
||||||
const [status, setStatus] = useState<BackupStatus | null>(null);
|
const [status, setStatus] = useState<BackupStatus | null>(null);
|
||||||
const [backingUp, setBackingUp] = useState(false);
|
const [backingUp, setBackingUp] = useState(false);
|
||||||
@@ -32,6 +40,20 @@ export default function DataBackupPanel() {
|
|||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
useReturnFocus(restorePath !== null);
|
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 () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setStatus(await api.getBackupStatus());
|
setStatus(await api.getBackupStatus());
|
||||||
@@ -229,6 +251,19 @@ export default function DataBackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</Tile>
|
</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
|
<Modal
|
||||||
open={restorePath !== null}
|
open={restorePath !== null}
|
||||||
danger={step === "confirm"}
|
danger={step === "confirm"}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
type FormProblems,
|
type FormProblems,
|
||||||
} from "../lib/erpnextUi";
|
} from "../lib/erpnextUi";
|
||||||
import { useToast } from "./ToastProvider";
|
import { useToast } from "./ToastProvider";
|
||||||
|
import { unitLabel } from "../lib/units";
|
||||||
|
|
||||||
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
|
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
|
||||||
|
|
||||||
@@ -496,12 +497,12 @@ export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
|
|||||||
/>
|
/>
|
||||||
<fieldset className="voiced-erp__uoms">
|
<fieldset className="voiced-erp__uoms">
|
||||||
<legend>Units of measure</legend>
|
<legend>Units of measure</legend>
|
||||||
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.</p>
|
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities. A custom unit typed on an invoice is sent as Nos. Month is not a stock ERPNext unit: create it there or pick another.</p>
|
||||||
{UOM_UNITS.map((unit) => (
|
{UOM_UNITS.map((unit) => (
|
||||||
<PickField
|
<PickField
|
||||||
key={unit}
|
key={unit}
|
||||||
id={`erp-uom-${unit}`}
|
id={`erp-uom-${unit}`}
|
||||||
label={`Voiced ${unit}`}
|
label={`Voiced ${unitLabel(unit).toLowerCase()}`}
|
||||||
value={form.uomMap[unit] ?? ""}
|
value={form.uomMap[unit] ?? ""}
|
||||||
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
|
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
|
||||||
items={opts.uoms}
|
items={opts.uoms}
|
||||||
|
|||||||
@@ -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,16 +59,16 @@ 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}
|
||||||
>
|
/>
|
||||||
<div className="voiced-notice-actions">
|
{/* Beside the notification, not inside it: Carbon throws on interactive children. */}
|
||||||
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
<div className="voiced-notice-actions">
|
||||||
Open file
|
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
||||||
</Button>
|
Open file
|
||||||
<Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}>
|
</Button>
|
||||||
Show in folder
|
<Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}>
|
||||||
</Button>
|
Show in folder
|
||||||
</div>
|
</Button>
|
||||||
</InlineNotification>
|
</div>
|
||||||
{result.identicalToIssued === false ? (
|
{result.identicalToIssued === false ? (
|
||||||
<InlineNotification
|
<InlineNotification
|
||||||
kind="warning"
|
kind="warning"
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
} from "@carbon/react";
|
} from "@carbon/react";
|
||||||
import { Add, Edit, TrashCan } from "@carbon/icons-react";
|
import { Add, Edit, TrashCan } from "@carbon/icons-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types";
|
import { blankPreset, type ItemPreset, type LineMode } from "../lib/types";
|
||||||
|
import { isValidUnit, ratePerText } from "../lib/units";
|
||||||
|
import UnitPicker from "./UnitPicker";
|
||||||
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
|
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
|
||||||
import { formatAmount } from "../lib/format";
|
import { formatAmount } from "../lib/format";
|
||||||
import { useToast } from "./ToastProvider";
|
import { useToast } from "./ToastProvider";
|
||||||
@@ -61,9 +63,12 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
|
|||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
setTouched(true);
|
setTouched(true);
|
||||||
if (!editing.description.trim()) return;
|
if (!editing.description.trim()) return;
|
||||||
|
if (editing.mode === "rate" && !isValidUnit(editing.unit)) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.saveItemPreset({ ...editing, description: editing.description.trim() });
|
// A fixed-amount preset has no unit of its own; whatever the picker last held must not block the save.
|
||||||
|
const unit = isValidUnit(editing.unit) ? editing.unit : "unit";
|
||||||
|
await api.saveItemPreset({ ...editing, unit, description: editing.description.trim() });
|
||||||
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
|
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
await load();
|
await load();
|
||||||
@@ -123,7 +128,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
|
|||||||
<TableRow key={p.id ?? p.description}>
|
<TableRow key={p.id ?? p.description}>
|
||||||
<TableCell>{p.description}</TableCell>
|
<TableCell>{p.description}</TableCell>
|
||||||
<TableCell>{p.hsnSac || "—"}</TableCell>
|
<TableCell>{p.hsnSac || "—"}</TableCell>
|
||||||
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell>
|
<TableCell>{p.mode === "rate" ? ratePerText(p.unit) : "Fixed amount"}</TableCell>
|
||||||
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
|
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="voiced-header-action">
|
<div className="voiced-header-action">
|
||||||
@@ -199,16 +204,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
|
|||||||
</Column>
|
</Column>
|
||||||
{editing.mode === "rate" ? (
|
{editing.mode === "rate" ? (
|
||||||
<Column sm={4} md={4} lg={8}>
|
<Column sm={4} md={4} lg={8}>
|
||||||
<Select
|
<UnitPicker id="ip-unit" value={editing.unit} onChange={(unit) => set("unit", unit)} />
|
||||||
id="ip-unit"
|
|
||||||
labelText="Unit"
|
|
||||||
value={editing.unit}
|
|
||||||
onChange={(e) => set("unit", e.target.value as LineUnit)}
|
|
||||||
>
|
|
||||||
{Object.entries(UNIT_LABELS).map(([value, text]) => (
|
|
||||||
<SelectItem key={value} value={value} text={text} />
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Column>
|
</Column>
|
||||||
) : null}
|
) : null}
|
||||||
<Column sm={4} md={4} lg={8}>
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
|||||||
@@ -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,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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ export interface ErpnextConfig {
|
|||||||
paymentBankAccount: string;
|
paymentBankAccount: string;
|
||||||
tdsAccount: string;
|
tdsAccount: string;
|
||||||
defaultItemCode: string;
|
defaultItemCode: string;
|
||||||
/** Voiced unit (second, minute, hour, session, unit) to ERPNext UOM. */
|
/** Voiced unit id (see lib/units.ts) to ERPNext UOM. */
|
||||||
uomMap: Record<string, string>;
|
uomMap: Record<string, string>;
|
||||||
customerGroup: string;
|
customerGroup: string;
|
||||||
territory: string;
|
territory: string;
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ describe("settings form", () => {
|
|||||||
expect(input).not.toHaveProperty("apiSecretSet");
|
expect(input).not.toHaveProperty("apiSecretSet");
|
||||||
expect(input).not.toHaveProperty("lastDetectResult");
|
expect(input).not.toHaveProperty("lastDetectResult");
|
||||||
expect(input.apiSecret).toBe("");
|
expect(input.apiSecret).toBe("");
|
||||||
expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" });
|
expect(input.uomMap).toMatchObject({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos", km: "Kilometer", sqft: "Square Foot" });
|
||||||
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
|
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
|
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
|
||||||
|
import { UNITS } from "./units";
|
||||||
import type {
|
import type {
|
||||||
ErpnextConfig,
|
ErpnextConfig,
|
||||||
ErpnextConfigInput,
|
ErpnextConfigInput,
|
||||||
@@ -259,14 +260,8 @@ export function versionsLine(test: ErpnextConnectionTest): string {
|
|||||||
|
|
||||||
// ---- settings form ----
|
// ---- settings form ----
|
||||||
|
|
||||||
export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const;
|
export const UOM_UNITS: readonly string[] = UNITS.map((u) => u.id);
|
||||||
export const DEFAULT_UOM_MAP: Record<string, string> = {
|
export const DEFAULT_UOM_MAP: Record<string, string> = Object.fromEntries(UNITS.map((u) => [u.id, u.uom]));
|
||||||
second: "Second",
|
|
||||||
minute: "Minute",
|
|
||||||
hour: "Hour",
|
|
||||||
session: "Nos",
|
|
||||||
unit: "Nos",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
|
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
|
||||||
const out: Record<string, string> = { ...DEFAULT_UOM_MAP };
|
const out: Record<string, string> = { ...DEFAULT_UOM_MAP };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { computeLineAmount, computeTotals } from "./invoice";
|
import { computeLineAmount, computeTotals } from "./invoice";
|
||||||
import { EMPTY_ITEM, type InvoiceItem } from "./types";
|
import { EMPTY_ITEM, type InvoiceItem } from "./types";
|
||||||
|
import { parseQuantity } from "./units";
|
||||||
|
|
||||||
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
|
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
|
||||||
// and the stored totals stay identical.
|
// and the stored totals stay identical.
|
||||||
@@ -40,6 +41,19 @@ describe("computeTotals", () => {
|
|||||||
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
|
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts a typed time as its decimal and rounds once, like Rust (units test: lines_can_use_any_unit...)", () => {
|
||||||
|
const q = (text: string, unit: string) => {
|
||||||
|
const r = parseQuantity(text, unit);
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return r.value;
|
||||||
|
};
|
||||||
|
expect(computeLineAmount(rate(1200, q("4:30", "hour")))).toBe(5400);
|
||||||
|
// 4 h 20 min is 4.333...; 1200 x that is 5200.00 only if the exact value is kept, not 4.33.
|
||||||
|
expect(computeLineAmount(rate(1200, q("4:20", "hour")))).toBe(5200);
|
||||||
|
expect(computeLineAmount({ ...rate(18, q("12.75", "km")), unit: "km" })).toBe(229.5);
|
||||||
|
expect(computeLineAmount(rate(99.5, 3))).toBe(298.5);
|
||||||
|
});
|
||||||
|
|
||||||
it("clamps the discount to the subtotal", () => {
|
it("clamps the discount to the subtotal", () => {
|
||||||
const items = [fixed(100), fixed(50.5)];
|
const items = [fixed(100), fixed(50.5)];
|
||||||
const over = computeTotals(items, 999.99, "igst", 18);
|
const over = computeTotals(items, 999.99, "igst", 18);
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type { BankAccount, Invoice, InvoiceInput, Settings } from "./types";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The end-to-end invoice flow through the real IPC, run against the fresh database the self-test
|
* The end-to-end invoice flow through the real IPC, run against the fresh database the self-test
|
||||||
* launches with (it seeds the unregistered vendor, a default bank and an active series).
|
* launches with. The fresh database is neutral (no vendor, no bank), so the first step provisions a
|
||||||
|
* fake vendor and bank the way the setup wizard would.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const today = () => {
|
const today = () => {
|
||||||
@@ -28,11 +29,32 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
|
|||||||
let bank!: BankAccount;
|
let bank!: BankAccount;
|
||||||
let invoice: Invoice | null = null;
|
let invoice: Invoice | null = null;
|
||||||
|
|
||||||
const loaded = await log.run("e2e: get settings and default bank", async () => {
|
const loaded = await log.run("e2e: provision vendor and bank, get settings", async () => {
|
||||||
s = await api.getSettings();
|
s = await api.getSettings();
|
||||||
const banks = await api.listBanks();
|
let banks = await api.listBanks();
|
||||||
|
if (banks.length === 0) {
|
||||||
|
const saved = await api.saveBank({
|
||||||
|
id: null,
|
||||||
|
label: "Primary",
|
||||||
|
bankName: "Self-test Bank",
|
||||||
|
accountName: "Self-test Vendor",
|
||||||
|
accountNo: "000000000000",
|
||||||
|
branch: "000001",
|
||||||
|
ifsc: "TEST0000001",
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
s = await api.saveSettings({
|
||||||
|
...s,
|
||||||
|
vendorName: s.vendorName || "Self-test Vendor",
|
||||||
|
vendorAddress: s.vendorAddress || "1 Test Road, Bengaluru - 560001",
|
||||||
|
vendorStateCode: s.vendorStateCode || "29",
|
||||||
|
defaultBankId: saved.id,
|
||||||
|
onboarded: true,
|
||||||
|
});
|
||||||
|
banks = await api.listBanks();
|
||||||
|
}
|
||||||
const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0];
|
const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0];
|
||||||
if (!found) throw new Error("the fresh database has no bank account");
|
if (!found) throw new Error("no bank account after provisioning");
|
||||||
bank = found;
|
bank = found;
|
||||||
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
|
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
|
||||||
});
|
});
|
||||||
@@ -80,7 +102,6 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let inv!: Invoice;
|
|
||||||
const issued = await log.run("e2e: issue and archive", async () => {
|
const issued = await log.run("e2e: issue and archive", async () => {
|
||||||
const outcome = await issueAndArchive(input, prefs, deps);
|
const outcome = await issueAndArchive(input, prefs, deps);
|
||||||
invoice = outcome.invoice;
|
invoice = outcome.invoice;
|
||||||
@@ -90,7 +111,7 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (!issued.ok || !invoice) return;
|
if (!issued.ok || !invoice) return;
|
||||||
inv = invoice as Invoice;
|
const inv = invoice as Invoice;
|
||||||
|
|
||||||
await log.run("e2e: archive status", async () => {
|
await log.run("e2e: archive status", async () => {
|
||||||
const st = await api.archiveStatus(inv.id);
|
const st = await api.archiveStatus(inv.id);
|
||||||
@@ -103,6 +124,8 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
|
|||||||
return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` };
|
return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: export notices render without crashing the window", async () => (await import("./selfTestUi")).checkExportFeedbackRenders());
|
||||||
|
|
||||||
await log.run("e2e: export flattened original", async () => {
|
await log.run("e2e: export flattened original", async () => {
|
||||||
const r = await exportWith("flattened", "original", flattenedPath);
|
const r = await exportWith("flattened", "original", flattenedPath);
|
||||||
return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` };
|
return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` };
|
||||||
@@ -136,6 +159,17 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: a new invoice can be issued and exported after the first", async () => {
|
||||||
|
const first = inv.number;
|
||||||
|
const outcome = await issueAndArchive({ ...input, notes: "Self-test invoice 2" }, prefs, deps);
|
||||||
|
const next = `${outDir}/e2e-second.pdf`;
|
||||||
|
const r = await exportInvoice({ invoice: outcome.invoice, mode: "searchable", source: "original" }, { ...deps, save: async () => next });
|
||||||
|
return {
|
||||||
|
ok: outcome.archived && outcome.invoice.number !== first && r?.path === next,
|
||||||
|
detail: `${first}, then ${outcome.invoice.number} (archived=${outcome.archived}), exported ${r?.path}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
await log.run("e2e: archiving different bytes is refused", async () => {
|
await log.run("e2e: archiving different bytes is refused", async () => {
|
||||||
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
|
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
|
||||||
const before = await api.readArchive(inv.id);
|
const before = await api.readArchive(inv.id);
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { Theme } from "@carbon/react";
|
||||||
|
import ExportFeedback from "../components/ExportFeedback";
|
||||||
|
import type { InvoiceExport } from "../hooks/useInvoiceExport";
|
||||||
|
import type { Invoice } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Part of the real-webview self-test: mounts the export notices the way the app does and fails if React reports an
|
||||||
|
* uncaught error. Carbon throws when a notification has a button inside it, and that took the whole window
|
||||||
|
* down after every successful export; nothing that runs without a real DOM can see it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
const idle: InvoiceExport = {
|
||||||
|
busy: false,
|
||||||
|
progress: null,
|
||||||
|
request: null,
|
||||||
|
error: null,
|
||||||
|
result: null,
|
||||||
|
start: async () => null,
|
||||||
|
cancel: noop,
|
||||||
|
retry: noop,
|
||||||
|
exportSearchableInstead: noop,
|
||||||
|
dismiss: noop,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CASES: Array<{ name: string; expect: string; exp: InvoiceExport }> = [
|
||||||
|
{
|
||||||
|
name: "saved",
|
||||||
|
expect: "Saved to",
|
||||||
|
exp: { ...idle, result: { path: "/tmp/voiced-selftest.pdf", mode: "searchable", invoice: {} as Invoice, identicalToIssued: false, archiveError: "not archived" } },
|
||||||
|
},
|
||||||
|
{ name: "failed", expect: "was not exported", exp: { ...idle, error: { message: "disk full", flatten: false } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function checkExportFeedbackRenders(): Promise<{ ok: boolean; detail: string }> {
|
||||||
|
const problems: string[] = [];
|
||||||
|
for (const c of CASES) {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const errors: string[] = [];
|
||||||
|
const root = createRoot(host, {
|
||||||
|
onUncaughtError: (e) => errors.push(e instanceof Error ? e.message : String(e)),
|
||||||
|
onRecoverableError: (e) => errors.push(e instanceof Error ? e.message : String(e)),
|
||||||
|
});
|
||||||
|
root.render(
|
||||||
|
<Theme theme="g10">
|
||||||
|
<ExportFeedback exp={c.exp} />
|
||||||
|
</Theme>,
|
||||||
|
);
|
||||||
|
// Effects (where Carbon checks its children) run after the first paint.
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
if (errors.length > 0) problems.push(`${c.name}: ${errors.join("; ").slice(0, 300)}`);
|
||||||
|
else if (!(host.textContent ?? "").includes(c.expect)) problems.push(`${c.name}: the notice did not render`);
|
||||||
|
root.unmount();
|
||||||
|
host.remove();
|
||||||
|
}
|
||||||
|
return { ok: problems.length === 0, detail: problems.join(" | ") || "the saved and failed notices render without throwing" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type ThemeName = "g10" | "g100";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "voiced.theme";
|
||||||
|
|
||||||
|
export const normalizeTheme = (value: string | null | undefined): ThemeName => (value === "g100" ? "g100" : "g10");
|
||||||
|
|
||||||
|
/** The theme last applied on this machine; used before settings load so there is no light flash. */
|
||||||
|
export function cachedTheme(): ThemeName {
|
||||||
|
try {
|
||||||
|
return normalizeTheme(localStorage.getItem(STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return "g10";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the Carbon theme tokens on <html> as well as on the <Theme> zone, so the page canvas, the
|
||||||
|
* overscroll area and any window area below the content follow the chosen theme.
|
||||||
|
* index.html runs the same class switch before React mounts.
|
||||||
|
*/
|
||||||
|
export function applyDocumentTheme(theme: ThemeName): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove("cds--g10", "cds--g100");
|
||||||
|
root.classList.add(`cds--${theme}`);
|
||||||
|
root.dataset.carbonTheme = theme;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
} catch {
|
||||||
|
/* storage may be blocked; the class is already applied */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export type TaxType = "none" | "cgst_sgst" | "igst";
|
export type TaxType = "none" | "cgst_sgst" | "igst";
|
||||||
export type LineMode = "fixed" | "rate";
|
export type LineMode = "fixed" | "rate";
|
||||||
export type LineUnit = "second" | "minute" | "hour" | "session" | "unit";
|
/** A built-in unit id (see lib/units.ts) or, for a custom unit, the text the vendor typed. */
|
||||||
|
export type LineUnit = string;
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
vendorName: string;
|
vendorName: string;
|
||||||
@@ -273,7 +274,7 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
|
|||||||
{ code: "09", name: "Uttar Pradesh" },
|
{ code: "09", name: "Uttar Pradesh" },
|
||||||
{ code: "10", name: "Bihar" },
|
{ code: "10", name: "Bihar" },
|
||||||
{ code: "11", name: "Sikkim" },
|
{ code: "11", name: "Sikkim" },
|
||||||
{ code: "12", name: "Sampleachal Pradesh" },
|
{ code: "12", name: "Arunachal Pradesh" },
|
||||||
{ code: "13", name: "Nagaland" },
|
{ code: "13", name: "Nagaland" },
|
||||||
{ code: "14", name: "Manipur" },
|
{ code: "14", name: "Manipur" },
|
||||||
{ code: "15", name: "Mizoram" },
|
{ code: "15", name: "Mizoram" },
|
||||||
@@ -302,10 +303,3 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
|
|||||||
{ code: "97", name: "Other Territory" },
|
{ code: "97", name: "Other Territory" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const UNIT_LABELS: Record<LineUnit, string> = {
|
|
||||||
second: "per second",
|
|
||||||
minute: "per minute",
|
|
||||||
hour: "per hour",
|
|
||||||
session: "per session",
|
|
||||||
unit: "per unit",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
UNITS,
|
||||||
|
formatQuantity,
|
||||||
|
normalizeUnit,
|
||||||
|
parseQuantity,
|
||||||
|
quantityText,
|
||||||
|
ratePerText,
|
||||||
|
unitShort,
|
||||||
|
validateCustomUnit,
|
||||||
|
} from "./units";
|
||||||
|
|
||||||
|
const value = (text: string, unit: string) => {
|
||||||
|
const r = parseQuantity(text, unit);
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
return r.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("parseQuantity", () => {
|
||||||
|
it("reads h:mm as hours for hour units", () => {
|
||||||
|
expect(value("4:30", "hour")).toBe(4.5);
|
||||||
|
expect(value("0:15", "hour")).toBe(0.25);
|
||||||
|
expect(value("4:20", "hour")).toBeCloseTo(4 + 1 / 3, 12);
|
||||||
|
expect(value("1:00:30", "hour")).toBeCloseTo(1 + 30 / 3600, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads m:ss as minutes for minute units", () => {
|
||||||
|
expect(value("4:30", "minute")).toBe(4.5);
|
||||||
|
expect(parseQuantity("1:00:30", "minute").ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a clock quantity for other units and out-of-range parts", () => {
|
||||||
|
expect(parseQuantity("4:30", "km").ok).toBe(false);
|
||||||
|
expect(parseQuantity("4:75", "hour").ok).toBe(false);
|
||||||
|
expect(parseQuantity("4:", "hour").ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads plain decimals for every unit", () => {
|
||||||
|
expect(value("12.75", "km")).toBe(12.75);
|
||||||
|
expect(value("120.5", "sqft")).toBe(120.5);
|
||||||
|
expect(value("3", "unit")).toBe(3);
|
||||||
|
expect(value(".5", "hour")).toBe(0.5);
|
||||||
|
expect(value("2.", "kg")).toBe(2);
|
||||||
|
expect(value("", "hour")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects text, negatives, more than 3 decimals and huge values", () => {
|
||||||
|
for (const bad of ["abc", "-1", "1,5", "1.2345", ".", "1e3", "99999999"]) {
|
||||||
|
expect(parseQuantity(bad, "hour").ok, bad).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatQuantity", () => {
|
||||||
|
it("trims zeros", () => {
|
||||||
|
expect(formatQuantity(2, "unit")).toBe("2");
|
||||||
|
expect(formatQuantity(1.5, "km")).toBe("1.5");
|
||||||
|
expect(formatQuantity(12.75, "km")).toBe("12.75");
|
||||||
|
expect(formatQuantity(0.125, "kg")).toBe("0.125");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a repeating time as h:mm", () => {
|
||||||
|
expect(formatQuantity(value("4:20", "hour"), "hour")).toBe("4:20");
|
||||||
|
expect(formatQuantity(value("0:20", "hour"), "hour")).toBe("0:20");
|
||||||
|
expect(formatQuantity(value("1:00:30", "hour"), "hour")).toBe("1:00:30");
|
||||||
|
expect(formatQuantity(value("4:30", "hour"), "hour")).toBe("4.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips whatever parseQuantity accepts", () => {
|
||||||
|
for (const [text, unit] of [["4:20", "hour"], ["7:45", "hour"], ["12.75", "km"], ["0:05", "hour"], ["3:10", "minute"]] as const) {
|
||||||
|
const v = value(text, unit);
|
||||||
|
expect(value(formatQuantity(v, unit), unit)).toBeCloseTo(v, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unit text", () => {
|
||||||
|
it("keeps the old rate wording for the original five units", () => {
|
||||||
|
expect(["second", "minute", "hour", "session", "unit"].map(ratePerText)).toEqual([
|
||||||
|
"per second",
|
||||||
|
"per minute",
|
||||||
|
"per hour",
|
||||||
|
"per session",
|
||||||
|
"per unit",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the unit beside the quantity, but not for the generic unit", () => {
|
||||||
|
expect(quantityText(4.5, "hour")).toBe("4.5 hr");
|
||||||
|
expect(quantityText(12.75, "km")).toBe("12.75 km");
|
||||||
|
expect(quantityText(3, "unit")).toBe("3");
|
||||||
|
expect(quantityText(2, "bundle")).toBe("2 bundle");
|
||||||
|
expect(unitShort("sqft")).toBe("sq ft");
|
||||||
|
expect(ratePerText("bundle")).toBe("per bundle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates custom units", () => {
|
||||||
|
expect(validateCustomUnit("bundle")).toBeNull();
|
||||||
|
expect(validateCustomUnit("sq.ft/day")).toBeNull();
|
||||||
|
expect(validateCustomUnit("")).not.toBeNull();
|
||||||
|
expect(validateCustomUnit("a".repeat(13))).not.toBeNull();
|
||||||
|
expect(validateCustomUnit("₹")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises stored values", () => {
|
||||||
|
expect(normalizeUnit("HOUR")).toBe("hour");
|
||||||
|
expect(normalizeUnit(" bundle ")).toBe("bundle");
|
||||||
|
expect(normalizeUnit("")).toBe("unit");
|
||||||
|
expect(normalizeUnit(undefined)).toBe("unit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has unique ids and a short for every unit except the generic one", () => {
|
||||||
|
expect(new Set(UNITS.map((u) => u.id)).size).toBe(UNITS.length);
|
||||||
|
for (const u of UNITS) if (u.id !== "unit") expect(u.short).not.toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Units of measure for rate × quantity lines. The ids of the first five are stored in saved invoices
|
||||||
|
* and must never change. Any other text is a custom unit and is stored as typed (see `validateCustomUnit`).
|
||||||
|
* The Rust side keeps the same rules in src-tauri/src/units.rs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UnitGroup = "Count" | "Time" | "Distance" | "Area" | "Weight" | "Volume";
|
||||||
|
|
||||||
|
/** How `h:mm` style quantities are read for a unit: "4:30" is 4 h 30 min for hours, 4 min 30 s for minutes. */
|
||||||
|
export type ClockKind = "hours" | "minutes";
|
||||||
|
|
||||||
|
export interface UnitDef {
|
||||||
|
id: string;
|
||||||
|
/** Shown in pickers: "Hour". */
|
||||||
|
label: string;
|
||||||
|
/** Lower-case word used after "per": "per hour". */
|
||||||
|
noun: string;
|
||||||
|
/** Short form beside a quantity: "4.5 hr". Empty for the generic "unit", which stays a bare number. */
|
||||||
|
short: string;
|
||||||
|
group: UnitGroup;
|
||||||
|
clock?: ClockKind;
|
||||||
|
/** ERPNext UOM used when the integration has no mapping of its own for this unit. */
|
||||||
|
uom: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNITS: readonly UnitDef[] = [
|
||||||
|
{ id: "unit", label: "Unit", noun: "unit", short: "", group: "Count", uom: "Nos" },
|
||||||
|
{ id: "piece", label: "Piece", noun: "piece", short: "pc", group: "Count", uom: "Nos" },
|
||||||
|
{ id: "set", label: "Set", noun: "set", short: "set", group: "Count", uom: "Set" },
|
||||||
|
{ id: "session", label: "Session", noun: "session", short: "session", group: "Time", uom: "Nos" },
|
||||||
|
{ id: "second", label: "Second", noun: "second", short: "sec", group: "Time", uom: "Second" },
|
||||||
|
{ id: "minute", label: "Minute", noun: "minute", short: "min", group: "Time", clock: "minutes", uom: "Minute" },
|
||||||
|
{ id: "hour", label: "Hour", noun: "hour", short: "hr", group: "Time", clock: "hours", uom: "Hour" },
|
||||||
|
{ id: "day", label: "Day", noun: "day", short: "day", group: "Time", uom: "Day" },
|
||||||
|
{ id: "week", label: "Week", noun: "week", short: "wk", group: "Time", uom: "Week" },
|
||||||
|
{ id: "month", label: "Month", noun: "month", short: "mo", group: "Time", uom: "Month" },
|
||||||
|
{ id: "km", label: "Kilometre", noun: "km", short: "km", group: "Distance", uom: "Kilometer" },
|
||||||
|
{ id: "m", label: "Metre", noun: "m", short: "m", group: "Distance", uom: "Meter" },
|
||||||
|
{ id: "sqft", label: "Square foot", noun: "sq ft", short: "sq ft", group: "Area", uom: "Square Foot" },
|
||||||
|
{ id: "sqm", label: "Square metre", noun: "sq m", short: "sq m", group: "Area", uom: "Square Meter" },
|
||||||
|
{ id: "kg", label: "Kilogram", noun: "kg", short: "kg", group: "Weight", uom: "Kg" },
|
||||||
|
{ id: "litre", label: "Litre", noun: "litre", short: "L", group: "Volume", uom: "Litre" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const UNIT_GROUPS: readonly UnitGroup[] = ["Count", "Time", "Distance", "Area", "Weight", "Volume"];
|
||||||
|
|
||||||
|
const BY_ID = new Map(UNITS.map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
export const DEFAULT_UNIT = "unit";
|
||||||
|
export const MAX_CUSTOM_UNIT_LENGTH = 12;
|
||||||
|
|
||||||
|
/** The built-in unit with this id, or undefined for a custom unit. */
|
||||||
|
export const knownUnit = (unit: string): UnitDef | undefined => BY_ID.get(unit.trim().toLowerCase());
|
||||||
|
|
||||||
|
export const isKnownUnit = (unit: string): boolean => BY_ID.has(unit.trim().toLowerCase());
|
||||||
|
|
||||||
|
/** Problem with a custom unit text, or null when it is fine. */
|
||||||
|
export function validateCustomUnit(text: string): string | null {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return "Enter a unit";
|
||||||
|
if (t.length > MAX_CUSTOM_UNIT_LENGTH) return `Use at most ${MAX_CUSTOM_UNIT_LENGTH} characters`;
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9 ./-]*$/.test(t)) return "Use letters, digits, spaces, . / or -";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a stored unit can be issued: a built-in id or a valid custom text. */
|
||||||
|
export const isValidUnit = (unit: string): boolean => isKnownUnit(unit) || validateCustomUnit(unit) === null;
|
||||||
|
|
||||||
|
/** A unit value as stored: the id for a built-in unit, the trimmed text otherwise. Falls back to the default. */
|
||||||
|
export function normalizeUnit(unit: string | null | undefined): string {
|
||||||
|
const t = (unit ?? "").trim();
|
||||||
|
if (!t) return DEFAULT_UNIT;
|
||||||
|
const lower = t.toLowerCase();
|
||||||
|
if (BY_ID.has(lower)) return lower;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unitLabel = (unit: string): string => knownUnit(unit)?.label ?? unit.trim();
|
||||||
|
|
||||||
|
/** "hr" for hours; the text itself for a custom unit; "" for the generic unit. */
|
||||||
|
export const unitShort = (unit: string): string => {
|
||||||
|
const def = knownUnit(unit);
|
||||||
|
return def ? def.short : unit.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "per hour", "per sq ft", "per bundle". */
|
||||||
|
export const ratePerText = (unit: string): string => `per ${knownUnit(unit)?.noun ?? unit.trim()}`;
|
||||||
|
|
||||||
|
/** The ERPNext UOM that suits a built-in unit; undefined for a custom unit. */
|
||||||
|
export const defaultUom = (unit: string): string | undefined => knownUnit(unit)?.uom;
|
||||||
|
|
||||||
|
export type QuantityParse = { ok: true; value: number } | { ok: false; error: string };
|
||||||
|
|
||||||
|
const MAX_QUANTITY = 10_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads what the user typed into a quantity box. Plain decimals (up to 3 places) work for every unit.
|
||||||
|
* Hour units also take `h:mm` and `h:mm:ss`, minute units take `m:ss`, so "4:30" hours is 4.5.
|
||||||
|
* An empty box is 0, the same as before this existed.
|
||||||
|
*/
|
||||||
|
export function parseQuantity(text: string, unit: string): QuantityParse {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return { ok: true, value: 0 };
|
||||||
|
const clock = knownUnit(unit)?.clock;
|
||||||
|
|
||||||
|
if (t.includes(":")) {
|
||||||
|
if (!clock) return { ok: false, error: "Only time units take h:mm" };
|
||||||
|
const m = /^(\d{1,7}):(\d{1,2})(?::(\d{1,2}))?$/.exec(t);
|
||||||
|
if (!m) return { ok: false, error: clock === "hours" ? "Use h:mm, for example 4:30" : "Use m:ss, for example 4:30" };
|
||||||
|
const first = Number(m[1]);
|
||||||
|
const second = Number(m[2]);
|
||||||
|
const third = m[3] === undefined ? null : Number(m[3]);
|
||||||
|
if (second > 59 || (third !== null && third > 59)) return { ok: false, error: "Minutes and seconds go up to 59" };
|
||||||
|
if (clock === "minutes" && third !== null) return { ok: false, error: "Use m:ss, for example 4:30" };
|
||||||
|
const value = clock === "hours" ? first + second / 60 + (third ?? 0) / 3600 : first + second / 60;
|
||||||
|
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = /^(\d{0,8})(?:\.(\d{0,3}))?$/.exec(t);
|
||||||
|
if (!m || (m[1] === "" && !m[2])) {
|
||||||
|
return { ok: false, error: /^\d*\.\d{4,}$/.test(t) ? "Use at most 3 decimal places" : "Enter a number" };
|
||||||
|
}
|
||||||
|
const value = Number(t);
|
||||||
|
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EPS = 1e-9;
|
||||||
|
|
||||||
|
function trimmed(value: number, places: number): string {
|
||||||
|
return value.toFixed(places).replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quantity as text, without the unit. Exact for anything `parseQuantity` accepts: a time that is not a
|
||||||
|
* whole number of hundredths (4 h 20 min) is written as h:mm so that quantity × rate can be checked by eye.
|
||||||
|
*/
|
||||||
|
export function formatQuantity(value: number, unit: string): string {
|
||||||
|
if (!Number.isFinite(value)) return "0";
|
||||||
|
const clock = knownUnit(unit)?.clock;
|
||||||
|
if (!clock) return trimmed(value, 3);
|
||||||
|
if (Math.abs(value * 100 - Math.round(value * 100)) < EPS * 100) return trimmed(value, 2);
|
||||||
|
const whole = Math.floor(value + EPS);
|
||||||
|
const rest = (value - whole) * 60;
|
||||||
|
const min = Math.floor(rest + EPS);
|
||||||
|
const sec = Math.round((rest - min) * 60);
|
||||||
|
const head = clock === "hours" ? `${whole}:${String(min).padStart(2, "0")}` : null;
|
||||||
|
if (clock === "hours" && sec < 60 && Math.abs(whole + min / 60 + sec / 3600 - value) < EPS) {
|
||||||
|
return sec === 0 ? head! : `${head}:${String(sec).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
if (clock === "minutes") {
|
||||||
|
const s = Math.round((value - whole) * 60);
|
||||||
|
if (s < 60 && Math.abs(whole + s / 60 - value) < EPS) return `${whole}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return trimmed(value, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The quantity as printed on an invoice: "4:20 hr", "12.75 km", or a bare "3" for the generic unit. */
|
||||||
|
export function quantityText(value: number, unit: string): string {
|
||||||
|
const q = formatQuantity(value, unit);
|
||||||
|
const short = unitShort(unit);
|
||||||
|
return short ? `${q} ${short}` : q;
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import "./styles/carbon.scss";
|
import "./styles/carbon.scss";
|
||||||
import { maybeRunSelfTest } from "./selftestBoot";
|
import { maybeRunSelfTest } from "./selftestBoot";
|
||||||
|
|
||||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -210,10 +210,10 @@ describe("Row", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FitText and IdText", () => {
|
describe("FitText and IdText", () => {
|
||||||
const EMAIL60 = "Sample.longsurname.voiceover.studio.department@examplemedia.co.in".slice(0, 60);
|
const EMAIL60 = "anon.longsurname.voiceover.studio.department@examplemedia.co.in".slice(0, 60);
|
||||||
|
|
||||||
it("shrinks a medium e-mail to fit and keeps it on one line", async () => {
|
it("shrinks a medium e-mail to fit and keeps it on one line", async () => {
|
||||||
const email = "vendor.name@examplestudios.co.in";
|
const email = "anon.longsurname.voiceover@examplestudios.co.in";
|
||||||
let info!: FitInfo;
|
let info!: FitInfo;
|
||||||
const pdf = await renderPdfText(
|
const pdf = await renderPdfText(
|
||||||
frame(
|
frame(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const logo = (over: Partial<SlotLogoInput>): SlotLogoInput => ({
|
|||||||
hasKnockout: true,
|
hasKnockout: true,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
const Sample_P = logo({ aspect: 4.62, density: 0.437, kind: "wordmark", includesName: true });
|
const SAMPLE_LOGO = logo({ aspect: 4.62, density: 0.437, kind: "wordmark", includesName: true });
|
||||||
|
|
||||||
/** The box a logo is fitted to: the allowance for a text-only slot, else the lockup (or mark) box. */
|
/** The box a logo is fitted to: the allowance for a text-only slot, else the lockup (or mark) box. */
|
||||||
const lockupTarget = (s: TemplateSlot): Box => (s.textOnly ? s.allowance : s.lockup!);
|
const lockupTarget = (s: TemplateSlot): Box => (s.textOnly ? s.allowance : s.lockup!);
|
||||||
@@ -98,9 +98,9 @@ describe("computeLogoPlacement identity: the placeholder itself comes back as it
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("computeLogoPlacement text-only allowances", () => {
|
describe("computeLogoPlacement text-only allowances", () => {
|
||||||
it("Test Vendor in Monolith is 28 pt tall, about 129 wide, and keeps the right edge", () => {
|
it("The sample logo in Monolith is 28 pt tall, about 129 wide, and keeps the right edge", () => {
|
||||||
const s = SLOTS.monolith!;
|
const s = SLOTS.monolith!;
|
||||||
const p = computeLogoPlacement({ logo: Sample_P, slot: s });
|
const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s });
|
||||||
expect(p.h).toBeCloseTo(28, 6);
|
expect(p.h).toBeCloseTo(28, 6);
|
||||||
expect(p.w).toBeGreaterThanOrEqual(128);
|
expect(p.w).toBeGreaterThanOrEqual(128);
|
||||||
expect(p.w).toBeLessThanOrEqual(130);
|
expect(p.w).toBeLessThanOrEqual(130);
|
||||||
@@ -117,9 +117,9 @@ describe("computeLogoPlacement text-only allowances", () => {
|
|||||||
expect(p.x + p.w).toBeCloseTo(560, 6);
|
expect(p.x + p.w).toBeCloseTo(560, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Test Vendor in Tangerine fills 36 pt of height (about 166 wide) at the left edge", () => {
|
it("The sample logo in Tangerine fills 36 pt of height (about 166 wide) at the left edge", () => {
|
||||||
const s = SLOTS["tangerine-ledger"]!;
|
const s = SLOTS["tangerine-ledger"]!;
|
||||||
const p = computeLogoPlacement({ logo: Sample_P, slot: s });
|
const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s });
|
||||||
expect(p.h).toBeCloseTo(36, 6);
|
expect(p.h).toBeCloseTo(36, 6);
|
||||||
expect(p.w).toBeCloseTo(36 * 4.62, 6);
|
expect(p.w).toBeCloseTo(36 * 4.62, 6);
|
||||||
expect(p.x).toBeCloseTo(60.5, 6);
|
expect(p.x).toBeCloseTo(60.5, 6);
|
||||||
@@ -142,13 +142,13 @@ describe("computeLogoPlacement text-only allowances", () => {
|
|||||||
describe("computeLogoPlacement on every slot", () => {
|
describe("computeLogoPlacement on every slot", () => {
|
||||||
const all: [TemplateId, TemplateSlot][] = [...measured, ...optIn];
|
const all: [TemplateId, TemplateSlot][] = [...measured, ...optIn];
|
||||||
|
|
||||||
it("Test Vendor never exceeds its box and is never heavier than the placeholder ink", () => {
|
it("The sample logo never exceeds its box and is never heavier than the placeholder ink", () => {
|
||||||
for (const [id, s] of all) {
|
for (const [id, s] of all) {
|
||||||
const p = computeLogoPlacement({ logo: Sample_P, slot: s });
|
const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s });
|
||||||
expect(p.slotKind).toBe("lockup");
|
expect(p.slotKind).toBe("lockup");
|
||||||
expect(inside(p.box, lockupTarget(s)), id).toBe(true);
|
expect(inside(p.box, lockupTarget(s)), id).toBe(true);
|
||||||
if (!s.optIn && !s.textOnly) {
|
if (!s.optIn && !s.textOnly) {
|
||||||
const ink = p.w * p.h * Sample_P.density;
|
const ink = p.w * p.h * SAMPLE_LOGO.density;
|
||||||
const placeholderInk = s.lockup!.w * s.lockup!.h * s.lockupDensity;
|
const placeholderInk = s.lockup!.w * s.lockup!.h * s.lockupDensity;
|
||||||
expect(ink, `${id}: ${ink.toFixed(1)} vs ${placeholderInk.toFixed(1)}`).toBeLessThanOrEqual(placeholderInk * 1.05);
|
expect(ink, `${id}: ${ink.toFixed(1)} vs ${placeholderInk.toFixed(1)}`).toBeLessThanOrEqual(placeholderInk * 1.05);
|
||||||
}
|
}
|
||||||
@@ -265,7 +265,7 @@ describe("computeLogoPlacement page scale and available room", () => {
|
|||||||
|
|
||||||
it("shrinks to the room the layout has", () => {
|
it("shrinks to the room the layout has", () => {
|
||||||
const s = SLOTS["teal-swoosh"]!;
|
const s = SLOTS["teal-swoosh"]!;
|
||||||
const p = computeLogoPlacement({ logo: Sample_P, slot: s, available: { w: 80, h: 100 } });
|
const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s, available: { w: 80, h: 100 } });
|
||||||
expect(p.w).toBeLessThanOrEqual(80 + 1e-9);
|
expect(p.w).toBeLessThanOrEqual(80 + 1e-9);
|
||||||
expect(p.x).toBeCloseTo(s.lockup!.x, 6);
|
expect(p.x).toBeCloseTo(s.lockup!.x, 6);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const BOX = { w: 170, h: 54 };
|
|||||||
const base = { box: BOX, availableWidth: 299, minTextWidth: 160 };
|
const base = { box: BOX, availableWidth: 299, minTextWidth: 160 };
|
||||||
|
|
||||||
describe("computeClassicLogoPlacement", () => {
|
describe("computeClassicLogoPlacement", () => {
|
||||||
it("the Test Vendor trimmed 4.6:1 logo is width-limited and stacks above the text", () => {
|
it("the sample trimmed 4.6:1 logo is width-limited and stacks above the text", () => {
|
||||||
const p = computeClassicLogoPlacement({ ...base, aspect: 4.6 });
|
const p = computeClassicLogoPlacement({ ...base, aspect: 4.6 });
|
||||||
expect(p.w).toBeCloseTo(170, 6);
|
expect(p.w).toBeCloseTo(170, 6);
|
||||||
expect(p.h).toBeCloseTo(170 / 4.6, 6);
|
expect(p.h).toBeCloseTo(170 / 4.6, 6);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const SAMPLES: string[] = [
|
|||||||
"Voice-over session 12 - narration for module 3",
|
"Voice-over session 12 - narration for module 3",
|
||||||
"Total ₹1,23,456.78",
|
"Total ₹1,23,456.78",
|
||||||
"AVATAR WAVE To Ty Yo",
|
"AVATAR WAVE To Ty Yo",
|
||||||
"vendor.name@examplestudios.co.in",
|
"anon.longsurname.voiceover@examplestudios.co.in",
|
||||||
"29ABCDE1234F1Z5",
|
"29ABCDE1234F1Z5",
|
||||||
"office fi fl ffi final",
|
"office fi fl ffi final",
|
||||||
"BILLED TO",
|
"BILLED TO",
|
||||||
@@ -130,7 +130,7 @@ describe("measurer helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips chunkIdentifier output", () => {
|
it("round-trips chunkIdentifier output", () => {
|
||||||
const id = "vendor.name@examplestudios.co.in";
|
const id = "anon.longsurname.voiceover@examplestudios.co.in";
|
||||||
expect(chunkIdentifier(id, 12).join("")).toBe(id);
|
expect(chunkIdentifier(id, 12).join("")).toBe(id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -181,6 +181,23 @@ export function getMeasurer(): Measurer {
|
|||||||
/** The text as it will be drawn: caps applied. */
|
/** The text as it will be drawn: caps applied. */
|
||||||
export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text);
|
export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text);
|
||||||
|
|
||||||
|
/** A quantity with its unit ("1250.5 sq ft") is allowed this much width before the cell wraps at its spaces. */
|
||||||
|
export const QTY_WRAP_CAP = 72;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The texts a quantity column is sized from. A quantity cell can wrap between its words ("120 / wooden crate")
|
||||||
|
* but never inside one, so a text wider than `cap` counts as its widest word; the column then stays modest
|
||||||
|
* and the description keeps its room, instead of one long custom unit squeezing the table.
|
||||||
|
*/
|
||||||
|
export function wrapAwareTexts(texts: string[], role: RoleName | TypeToken, cap = QTY_WRAP_CAP): string[] {
|
||||||
|
const m = getMeasurer();
|
||||||
|
return texts.map((text) => {
|
||||||
|
if (measureToken(m, role, text) <= cap) return text;
|
||||||
|
const words = text.split(/\s+/).filter(Boolean);
|
||||||
|
return words.reduce((widest, w) => (measureToken(m, role, w) > measureToken(m, role, widest) ? w : widest), words[0] ?? text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Width of `text` set in a role/token at an optional size override, tracking and caps included. */
|
/** Width of `text` set in a role/token at an optional size override, tracking and caps included. */
|
||||||
export function measureToken(
|
export function measureToken(
|
||||||
m: Measurer,
|
m: Measurer,
|
||||||
|
|||||||
@@ -133,7 +133,10 @@ describe("unregistered invoice", () => {
|
|||||||
descriptionLines: ["Voice-over", "session A"],
|
descriptionLines: ["Voice-over", "session A"],
|
||||||
hsnSac: "998397",
|
hsnSac: "998397",
|
||||||
rateText: "₹1,200.00 per hour",
|
rateText: "₹1,200.00 per hour",
|
||||||
qtyText: "2.5",
|
priceText: "₹1,200.00",
|
||||||
|
perText: "per hour",
|
||||||
|
rateShort: "₹1,200.00/hr",
|
||||||
|
qtyText: "2.5 hr",
|
||||||
amountText: "₹3,000.00",
|
amountText: "₹3,000.00",
|
||||||
});
|
});
|
||||||
expect(m.items[1]).toEqual({
|
expect(m.items[1]).toEqual({
|
||||||
@@ -141,10 +144,22 @@ describe("unregistered invoice", () => {
|
|||||||
descriptionLines: ["—"],
|
descriptionLines: ["—"],
|
||||||
hsnSac: null,
|
hsnSac: null,
|
||||||
rateText: null,
|
rateText: null,
|
||||||
|
priceText: null,
|
||||||
|
perText: null,
|
||||||
|
rateShort: null,
|
||||||
qtyText: "1",
|
qtyText: "1",
|
||||||
amountText: "₹2,500.50",
|
amountText: "₹2,500.50",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it("words the unit and the quantity for every kind of unit", () => {
|
||||||
|
const line = (unit: string, quantity: number, rate = 18.5) =>
|
||||||
|
make({ items: [{ ...base.items[0], mode: "rate", unit, quantity, rate }] }).items[0];
|
||||||
|
expect(line("km", 12.75)).toMatchObject({ qtyText: "12.75 km", rateText: "₹18.50 per km", rateShort: "₹18.50/km", perText: "per km" });
|
||||||
|
expect(line("sqft", 1250.5)).toMatchObject({ qtyText: "1250.5 sq ft", rateShort: "₹18.50/sq ft" });
|
||||||
|
expect(line("hour", 4 + 1 / 3)).toMatchObject({ qtyText: "4:20 hr" });
|
||||||
|
expect(line("unit", 3)).toMatchObject({ qtyText: "3", rateText: "₹18.50 per unit", rateShort: "₹18.50/unit" });
|
||||||
|
expect(line(" bundle ", 2)).toMatchObject({ qtyText: "2 bundle", rateText: "₹18.50 per bundle", rateShort: "₹18.50/bundle" });
|
||||||
|
});
|
||||||
it("labels the bank lines and defaults the signature to the vendor name", () => {
|
it("labels the bank lines and defaults the signature to the vendor name", () => {
|
||||||
expect(m.bank).toEqual({
|
expect(m.bank).toEqual({
|
||||||
accountName: "Test Vendor",
|
accountName: "Test Vendor",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computeLineAmount } from "../../lib/invoice";
|
import { computeLineAmount } from "../../lib/invoice";
|
||||||
|
import { quantityText, ratePerText, unitShort, knownUnit } from "../../lib/units";
|
||||||
import {
|
import {
|
||||||
INDIAN_STATES,
|
INDIAN_STATES,
|
||||||
UNIT_LABELS,
|
|
||||||
type BankAccount,
|
type BankAccount,
|
||||||
type Invoice,
|
type Invoice,
|
||||||
type Settings,
|
type Settings,
|
||||||
@@ -48,7 +48,15 @@ export interface RenderItem {
|
|||||||
sno: number;
|
sno: number;
|
||||||
descriptionLines: string[];
|
descriptionLines: string[];
|
||||||
hsnSac: string | null;
|
hsnSac: string | null;
|
||||||
|
/** "₹1,200.00 per hour"; null for a fixed-amount line. */
|
||||||
rateText: string | null;
|
rateText: string | null;
|
||||||
|
/** The amount alone: "₹1,200.00". */
|
||||||
|
priceText: string | null;
|
||||||
|
/** The unit wording alone: "per hour". */
|
||||||
|
perText: string | null;
|
||||||
|
/** The one-line form: "₹1,200.00/hr". */
|
||||||
|
rateShort: string | null;
|
||||||
|
/** "4.5 hr", "12.75 km", or a bare "3" for the generic unit; "1" for a fixed-amount line. */
|
||||||
qtyText: string;
|
qtyText: string;
|
||||||
amountText: string;
|
amountText: string;
|
||||||
}
|
}
|
||||||
@@ -140,6 +148,15 @@ function taxRowsFor(invoice: Invoice, supplierStateCode: string): TaxRow[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */
|
/** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */
|
||||||
|
function rateFields(item: Invoice["items"][number]): Pick<RenderItem, "rateText" | "priceText" | "perText" | "rateShort"> {
|
||||||
|
if (item.mode !== "rate") return { rateText: null, priceText: null, perText: null, rateShort: null };
|
||||||
|
const priceText = formatRupees(item.rate);
|
||||||
|
const perText = ratePerText(item.unit);
|
||||||
|
// The generic "unit" has no short form beside a quantity, but its rate still reads "/unit".
|
||||||
|
const suffix = unitShort(item.unit) || knownUnit(item.unit)?.noun || item.unit.trim();
|
||||||
|
return { rateText: `${priceText} ${perText}`, priceText, perText, rateShort: `${priceText}/${suffix}` };
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRenderModel(input: BuildInput): RenderModel {
|
export function buildRenderModel(input: BuildInput): RenderModel {
|
||||||
const { invoice, logo, signatureDataUri, appVersion } = input;
|
const { invoice, logo, signatureDataUri, appVersion } = input;
|
||||||
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor);
|
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor);
|
||||||
@@ -164,8 +181,8 @@ export function buildRenderModel(input: BuildInput): RenderModel {
|
|||||||
return lines.length ? lines : ["—"];
|
return lines.length ? lines : ["—"];
|
||||||
})(),
|
})(),
|
||||||
hsnSac: orNull(item.hsnSac),
|
hsnSac: orNull(item.hsnSac),
|
||||||
rateText: item.mode === "rate" ? `${formatRupees(item.rate)} ${UNIT_LABELS[item.unit]}` : null,
|
...rateFields(item),
|
||||||
qtyText: item.mode === "rate" ? String(item.quantity) : "1",
|
qtyText: item.mode === "rate" ? quantityText(item.quantity, item.unit) : "1",
|
||||||
amountText: formatRupees(computeLineAmount(item)),
|
amountText: formatRupees(computeLineAmount(item)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function sanitize(value: string | null | undefined): string {
|
|||||||
.replace(/\r\n?/g, "\n")
|
.replace(/\r\n?/g, "\n")
|
||||||
.replace(/[\t\n\v\f]/g, " ")
|
.replace(/[\t\n\v\f]/g, " ")
|
||||||
.replace(/ /g, " ")
|
.replace(/ /g, " ")
|
||||||
|
// eslint-disable-next-line no-control-regex -- stripping control characters is the point
|
||||||
.replace(/[\u0000-\u001F\u007F-\u009F]/g, "")
|
.replace(/[\u0000-\u001F\u007F-\u009F]/g, "")
|
||||||
.replace(/ {2,}/g, " ")
|
.replace(/ {2,}/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { RenderTxt } from "./blocks/RenderTxt";
|
|||||||
import { TableGuard, guardPresence } from "./blocks/TableGuard";
|
import { TableGuard, guardPresence } from "./blocks/TableGuard";
|
||||||
import { renderCore } from "./render/core";
|
import { renderCore } from "./render/core";
|
||||||
import { loadCanvas, inkPixels } from "./testing/rasterize";
|
import { loadCanvas, inkPixels } from "./testing/rasterize";
|
||||||
import { byId, doc, FAMILY, find, frameFor, PAD, PAGE_W, rawLayout, txt } from "./testing/docs";
|
import { byId, doc, FAMILY, find, frameFor, PAD, rawLayout, txt } from "./testing/docs";
|
||||||
import { FONTS_DIR, setupPdfTest } from "./testing/setup";
|
import { FONTS_DIR, setupPdfTest } from "./testing/setup";
|
||||||
import { stack } from "./fonts/register";
|
import { stack } from "./fonts/register";
|
||||||
|
|
||||||
|
|||||||