Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa83505997 | ||
|
|
65f3615661 | ||
|
|
5b18db3a77 | ||
|
|
9ba00045c9 | ||
|
|
b6cf46b883 | ||
|
|
e1031b3ecc | ||
|
|
4960e26d8c | ||
|
|
3cb0ac9236 | ||
|
|
9bdb142090 | ||
|
|
c78bf1a851 | ||
|
|
c3e2e05e89 | ||
|
|
e786580d42 | ||
|
|
6efb498409 | ||
|
|
ae80922d03 | ||
|
|
23dd283a42 | ||
|
|
8dc4d85e14 | ||
|
|
2ffbf0f66c | ||
|
|
d3b5fbb1b8 | ||
|
|
86b4d436fa | ||
|
|
92f952b136 | ||
|
|
1623b879e2 | ||
|
|
3510cc4260 |
@@ -0,0 +1,107 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Static analysis. Also the only job that runs on the weekly schedule, so newly published
|
||||
# Semgrep registry rules are applied to main even when nothing is pushed.
|
||||
security:
|
||||
runs-on: bongbetic-ci
|
||||
timeout-minutes: 15
|
||||
container:
|
||||
image: docker.io/semgrep/semgrep:1.178.0
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \
|
||||
&& git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \
|
||||
&& git checkout -q FETCH_HEAD
|
||||
- name: Semgrep
|
||||
run: semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error
|
||||
|
||||
lint:
|
||||
if: gitea.event_name != 'schedule'
|
||||
runs-on: bongbetic-ci
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \
|
||||
&& git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \
|
||||
&& git checkout -q FETCH_HEAD
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
- name: ESLint
|
||||
run: npx eslint .
|
||||
- name: Rust format check
|
||||
# rustfmt only. clippy is deliberately not run: it needs the webkit2gtk/gtk system libraries
|
||||
# and a full compile of the Tauri crate, which is too heavy for the shared CI host.
|
||||
working-directory: src-tauri
|
||||
run: |
|
||||
base=https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu
|
||||
curl --proto '=https' --tlsv1.2 -sSfO "$base/rustup-init" -O "$base/rustup-init.sha256"
|
||||
sha256sum -c rustup-init.sha256
|
||||
chmod +x rustup-init
|
||||
./rustup-init -y --no-modify-path --profile minimal --default-toolchain 1.99.0 -c rustfmt
|
||||
rm -f rustup-init rustup-init.sha256
|
||||
"$HOME/.cargo/bin/cargo" fmt --check
|
||||
- name: Duplicate code (jscpd)
|
||||
run: npx --yes jscpd@4.3.0
|
||||
|
||||
e2e:
|
||||
if: gitea.event_name != 'schedule'
|
||||
runs-on: bongbetic-ci
|
||||
timeout-minutes: 25
|
||||
container:
|
||||
# Keep this tag in step with the @playwright/test version in package.json.
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \
|
||||
&& git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \
|
||||
&& git checkout -q FETCH_HEAD
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Playwright smoke tests
|
||||
run: npx playwright test
|
||||
|
||||
# Advisory only: never blocks a merge.
|
||||
ai-review:
|
||||
if: gitea.event_name == 'pull_request'
|
||||
runs-on: bongbetic-ci
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: docker.io/pragent/pr-agent:0.47.0
|
||||
env:
|
||||
config__git_provider: gitea
|
||||
gitea__url: https://git.bongbetic.com
|
||||
gitea__personal_access_token: ${{ secrets.PR_AGENT_GITEA_TOKEN }}
|
||||
openrouter__key: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
config__model: ${{ vars.PR_AGENT_MODEL || 'openrouter/anthropic/claude-sonnet-5' }}
|
||||
# PR-Agent needs this for OpenRouter models it has no built-in context window for.
|
||||
config__custom_model_max_tokens: "200000"
|
||||
steps:
|
||||
- name: PR-Agent review
|
||||
run: |
|
||||
if [ -z "$gitea__personal_access_token" ] || [ -z "$openrouter__key" ]; then
|
||||
echo "::notice::PR-Agent secrets not configured; skipping AI review."
|
||||
exit 0
|
||||
fi
|
||||
pr-agent --pr_url="${{ gitea.event.pull_request.html_url }}" review
|
||||
@@ -13,13 +13,13 @@ jobs:
|
||||
build:
|
||||
runs-on: windows
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@stable # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
run: npm run app:build
|
||||
|
||||
- name: Upload installer
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
|
||||
with:
|
||||
name: voiced-windows
|
||||
path: src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
@@ -31,3 +31,7 @@ xwin/
|
||||
CLAUDE.md
|
||||
handoff.md
|
||||
.commandcode/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"threshold": 4.5,
|
||||
"reporters": ["console"],
|
||||
"absolute": false,
|
||||
"gitignore": true,
|
||||
"path": ["."],
|
||||
"ignore": [
|
||||
"**/node_modules/**",
|
||||
"**/package-lock.json",
|
||||
"**/Cargo.lock",
|
||||
"**/dist/**",
|
||||
"**/target/**",
|
||||
"public/**",
|
||||
"docs/**",
|
||||
"src-tauri/gen/**",
|
||||
"src-tauri/icons/**",
|
||||
"**/*.{woff,woff2,ttf,otf,png,jpg,jpeg,gif,ico,icns,svg,pdf}",
|
||||
"**/playwright-report/**",
|
||||
"**/test-results/**"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# CI
|
||||
|
||||
Workflow: `.gitea/workflows/ci.yml`, runner label `bongbetic-ci`. It runs on pull requests, pushes to `main` and manual dispatch. A weekly schedule (Monday 03:00 UTC) runs the `security` job only.
|
||||
|
||||
## Jobs
|
||||
|
||||
| Job | What it runs | Blocks merge? |
|
||||
| --- | --- | --- |
|
||||
| `security` | Semgrep (`p/default`, `p/owasp-top-ten`), fails on any finding | Yes |
|
||||
| `lint` | `npm ci`, `tsc --noEmit`, ESLint, `cargo fmt --check` (src-tauri), jscpd (threshold 4.5%) | Yes |
|
||||
| `e2e` | `npm run build`, then the Playwright smoke test (chromium) against `vite preview` | Yes |
|
||||
| `ai-review` | PR-Agent review comment, pull requests only | No (advisory, `continue-on-error`) |
|
||||
|
||||
There is no deploy job: Voiced ships release assets, not a hosted app.
|
||||
|
||||
Semgrep registry rules are fetched at run time, so a new rule can fail a previously green `main`. The weekly schedule surfaces that early. Triage with a fix or a narrow `// nosemgrep: <rule-id> -- reason`.
|
||||
|
||||
Rust clippy is deferred. It needs the webkit2gtk/gtk system libraries and a full compile of the Tauri crate, which is too heavy for the shared CI host (jobs are limited to 3 GB RAM / 2 CPU). Only `cargo fmt --check` runs, with a pinned minimal rustup toolchain (rustfmt only).
|
||||
|
||||
## Run locally
|
||||
|
||||
```sh
|
||||
# Semgrep (same image and rules as CI)
|
||||
podman run --rm -v "$PWD:/src:ro,Z" -w /src docker.io/semgrep/semgrep:1.178.0 \
|
||||
semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error
|
||||
|
||||
npm run lint # ESLint
|
||||
npx tsc --noEmit # typecheck
|
||||
(cd src-tauri && cargo fmt --check)
|
||||
npx jscpd@4.3.0 # duplicate code, reads .jscpd.json
|
||||
|
||||
npx playwright install chromium # once
|
||||
npm run build && npm run e2e
|
||||
```
|
||||
|
||||
Keep the `mcr.microsoft.com/playwright` image tag in the `e2e` job in step with the `@playwright/test` version in `package.json`.
|
||||
|
||||
## PR-Agent (advisory)
|
||||
|
||||
`ai-review` posts a review through PR-Agent using OpenRouter. It never pushes code and is skipped (with a notice) when the secrets are empty.
|
||||
|
||||
Repository secrets and variables:
|
||||
|
||||
- `OPENROUTER_API_KEY` (secret): OpenRouter API key.
|
||||
- `PR_AGENT_GITEA_TOKEN` (secret): Gitea personal access token of the account that posts the review.
|
||||
- `PR_AGENT_MODEL` (variable, optional): defaults to `openrouter/anthropic/claude-sonnet-5`.
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the workflow PR. If branch protection requires these checks (`security`, `lint`, `e2e`), relax it first, otherwise PRs will wait forever for checks that no longer run.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Contributing to Voiced
|
||||
|
||||
Contributions are welcome: bug reports, fixes, templates, translations and documentation.
|
||||
|
||||
1. Open an issue first for anything larger than a small fix, so the work is not duplicated.
|
||||
2. Branch from `main`. Keep a change focused on one thing.
|
||||
3. Run the gates from `docs/RELEASE.md` that your change touches: `npm run build`, `npm test`, `npm run fonts:verify`
|
||||
and `cargo test --manifest-path src-tauri/Cargo.toml`. Changes to the PDF engine or a template also need the
|
||||
real-webview self-test (see the README, "Tests and gates").
|
||||
4. Do not add personal data, real GSTINs, bank details or client names to code, tests, fixtures or screenshots. Use
|
||||
synthetic values.
|
||||
5. Open a pull request describing what changed and why.
|
||||
|
||||
By submitting a contribution you agree that it is licensed under the Apache License 2.0 (see `LICENSE`), the same
|
||||
licence as the project, as described in section 5 of that licence.
|
||||
|
||||
## Using Voiced in your own project
|
||||
|
||||
You may fork, modify and redistribute Voiced under the Apache License 2.0. You must keep `LICENSE` and `NOTICE`,
|
||||
keep the copyright notices, and mark files you changed. Please also credit the original: "Based on Voiced by
|
||||
Bongbetic".
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,11 @@
|
||||
Voiced
|
||||
Copyright 2026 Bongbetic
|
||||
|
||||
This product includes software developed by Bongbetic (https://git.bongbetic.com/xavierk/Voiced).
|
||||
|
||||
If you redistribute Voiced, or a work derived from it (including a fork), the Apache License 2.0 requires you to keep
|
||||
this NOTICE file, the LICENSE file and the copyright notices in the source files, and to state any changes you made.
|
||||
Please also keep the credit "Based on Voiced by Bongbetic" in your README and in your application's About or
|
||||
documentation.
|
||||
|
||||
Bundled fonts in public/fonts/ are covered by their own SIL Open Font License 1.1 texts (OFL.txt in each folder).
|
||||
@@ -1,84 +1,202 @@
|
||||
# Voiced
|
||||
|
||||
An offline-first desktop invoicing app for **Test Vendor**, built by **Bongbetic**.
|
||||
An offline-first GST invoicing desktop app built by **Bongbetic**.
|
||||
|
||||
Voiced turns a few details into a clean, GST-ready PDF invoice. It keeps a running
|
||||
invoice-number series in a local database, remembers your vendor and bank details, and
|
||||
exports print-ready PDFs — no account, no network required.
|
||||
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
|
||||
connection. The only optional network feature is the ERPNext push, which you configure yourself.
|
||||
|
||||
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
|
||||
|
||||
Current release: **1.0.2**. Installers and the Void package are attached to the release on the project's Gitea
|
||||
page (see "Installing").
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic invoice numbering** — a persistent series (e.g. `INV/2026-001`) that keeps
|
||||
counting up, with a one-click "start a new series" that resets the counter.
|
||||
- **Guided first-run setup** — your name, address, contact, PAN, GSTIN, logo, signature and
|
||||
bank details, pre-filled from your existing invoice and editable at any time.
|
||||
- **Two line-item modes** — a fixed amount, or a rate × quantity where the unit can be per
|
||||
second / minute / hour / session / unit (e.g. 10 sessions × ₹2,500).
|
||||
- **GST support** — toggle taxes on/off and split as **CGST + SGST** (within state) or
|
||||
**IGST** (inter-state), with live totals and Indian amount-in-words.
|
||||
- **Signatures** — attach a PNG/JPEG per invoice, or omit it and the invoice notes that it is
|
||||
computer-generated and needs no signature.
|
||||
- **PDF export** — a print-ready A4 invoice styled to match the original `format.pdf`.
|
||||
- **Saved clients**, invoice history with re-export, and light/dark (Carbon g10/g100) themes.
|
||||
- **Input validation** for PAN, IFSC, GSTIN, email and phone.
|
||||
- **First-run setup.** A four-step wizard (business, branding, bank, numbering) starts blank: no vendor, bank or tax
|
||||
details are pre-filled, and a business name and state are required. **Settings, Data, Run setup again** reopens it
|
||||
with your current details; invoices, clients and backups are kept, and numbering carries on unless you change the
|
||||
series prefix or digits.
|
||||
- **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series,
|
||||
drafts, issue and cancel. An issued invoice is immutable: it stores a snapshot of the vendor, client, GST figures
|
||||
and layout. Line items are a fixed amount or rate x quantity (per second, minute, hour, session or unit), with
|
||||
item presets.
|
||||
- **GST.** All money and tax maths is done in integer paise. CGST+SGST, IGST or UTGST follow from the supplier's
|
||||
state and the place of supply, and a mismatch is rejected. An unregistered vendor gets a plain "Invoice" with no
|
||||
tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words.
|
||||
- **Templates.** 14 templates: Voiced Standard (Classic) and 13 further designs (Linea,
|
||||
Monogram, Serenity, Crimson Grid, Tangerine Ledger, Slate Band, Teal Swoosh, Purple Pop, Citrus Split, Monolith,
|
||||
Cobalt Stripe, Marble, Highlighter). Your logo fills the template's logo slot; a few templates offer an opt-in
|
||||
logo position. Specs are in `docs/templates/`.
|
||||
- **Page setup.** A4 or Letter, and margin presets (template, narrow 36 pt, normal 54 pt).
|
||||
- **Export modes.** *Searchable* PDF (text layer kept) or *flattened* (pages rasterised at 300 DPI and rebuilt as
|
||||
an image-only PDF). Issued PDFs are archived by content hash, and "Original as issued" re-exports the exact
|
||||
archived bytes.
|
||||
- **Fonts.** 11 bundled font families (see "Fonts and licences"). The three commercial template faces (Now, Gotham,
|
||||
Open Sauce One) can be overridden with your own licensed files under Settings, Fonts.
|
||||
- **Clients and presets.** Saved clients with structured addresses, state and GST category; item presets.
|
||||
- **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding
|
||||
balance is cash plus TDS against the invoice total.
|
||||
- **History.** Filterable invoice list, detail view with payments, and CSV or JSON export of the list.
|
||||
- **Backup and restore.** Manual and automatic daily backups of the database, assets, PDF archive and imported fonts.
|
||||
- **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices
|
||||
(see `docs/voiced-invoice-v1.md`).
|
||||
- **ERPNext integration.** Push an invoice (as draft or submitted) with its PDF attached, and record payments, to an
|
||||
ERPNext v15 site.
|
||||
- Light and dark (Carbon g10/g100) themes.
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Choice |
|
||||
| --------- | ------------------------------------------------------------------- |
|
||||
| Shell | [Tauri v2](https://v2.tauri.app/) (Rust core, WebView2 on Windows) |
|
||||
| UI | React + TypeScript + [IBM Carbon Design System](https://carbondesignsystem.com/) (`@carbon/react`) |
|
||||
| Fonts | IBM Plex, self-hosted (works offline) |
|
||||
| Storage | SQLite via `rusqlite` (bundled) |
|
||||
| PDF | `@react-pdf/renderer` with bundled IBM Plex |
|
||||
| ------- | ------------------------------------------------------------------------------------------- |
|
||||
| Shell | [Tauri v2](https://v2.tauri.app/) (Rust core, WebKitGTK on Linux, WebView2 on Windows) |
|
||||
| UI | React 19 + TypeScript + [IBM Carbon](https://carbondesignsystem.com/) (`@carbon/react`) |
|
||||
| Storage | SQLite via `rusqlite` (bundled), numbered migrations |
|
||||
| PDF | `@react-pdf/renderer` 4.9.0 in a Web Worker, pdf.js for preview and flattening |
|
||||
| ERPNext | `reqwest` with rustls (ring provider); all HTTP runs in Rust |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 20+** and npm
|
||||
- **Rust** (stable) via [rustup](https://rustup.rs/)
|
||||
- Linux dev only: WebKitGTK 4.1, libsoup3, GTK3, librsvg, `pkg-config`, `clang`/`llvm`/`lld`
|
||||
- **Node.js 22** and npm
|
||||
- **Rust** stable, via [rustup](https://rustup.rs/)
|
||||
- Linux only: WebKitGTK 4.1, libsoup3, GTK3, librsvg, `pkg-config`, `clang`/`llvm`/`lld`
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run app:dev # launches the desktop app with hot reload
|
||||
npm run app:dev # tauri dev, served from localhost:1420 (does not exercise the tauri:// protocol)
|
||||
```
|
||||
|
||||
Other scripts:
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `npm run build` | `tsc --noEmit` and `vite build` (the type-check gate) |
|
||||
| `npm test` | `vitest run`, about 7 minutes; run on a quiet machine because load starves the sweep tests |
|
||||
| `npm run fonts:verify` | Checks the bundled fonts against `public/fonts/manifest.json` (`fonts:manifest` regenerates it) |
|
||||
| `npm run logo:measure` | Regenerates `src/pdf/templates/slots.generated.ts` from the reference template PDFs (not in the repo); must be idempotent |
|
||||
| `npm run thumbnails` | Regenerates `public/templates/*.png` from the synthetic sample invoice (neutral vendor and bank, generated wordmark) |
|
||||
| `node scripts/logo/make-sample-logo.mjs` | Regenerates the synthetic sample logo in `src/pdf/testing/fixtures/` |
|
||||
| `npm run templates:compare` | Reference vs Voiced side-by-side PNGs under `/tmp` (needs the reference PDFs, not in the repo) |
|
||||
| `cargo test --manifest-path src-tauri/Cargo.toml` | Rust unit tests |
|
||||
| `npm run app:build` | Production build for the current platform (custom-protocol) |
|
||||
|
||||
```bash
|
||||
npm run build # type-check + build the web frontend
|
||||
npm run app:build # package the app for the current platform
|
||||
```
|
||||
The release procedure, including the manual first-run checklist, is in `docs/RELEASE.md`.
|
||||
|
||||
## Building for Windows (cross-compile from Linux/macOS)
|
||||
## Installing
|
||||
|
||||
Tauri only supports the MSVC target. Cross-compiling uses [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin)
|
||||
and produces an **NSIS installer** plus a portable `.exe`. A `.msi` still requires a real
|
||||
Windows host.
|
||||
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
|
||||
`v1.0.2`).
|
||||
|
||||
- **Windows 10/11:** run `Voiced_1.0.2_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.0.2_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
|
||||
|
||||
On first launch the setup wizard asks for your business details, logo, bank account and invoice numbering. Nothing is
|
||||
pre-filled. Uninstalling does not delete your data (see "Where data lives"); to start from scratch use
|
||||
Settings, Data, Run setup again, or remove the data directory after taking a backup.
|
||||
|
||||
## Where data lives
|
||||
|
||||
The Tauri identifier is `com.bongbetic.voiced`. Voiced uses two directories, which differ only on Windows:
|
||||
|
||||
| What | Linux | Windows |
|
||||
| --- | --- | --- |
|
||||
| `app_data_dir`: database `voiced.db`, `assets/` (logos, signatures), `backups/` (`auto/`, `pre-restore-*`, pre-migration copies) | `~/.local/share/com.bongbetic.voiced/` | `%APPDATA%\com.bongbetic.voiced\` |
|
||||
| `app_local_data_dir`: `archive/<sha256>.pdf` (issued PDFs), `fonts/` (fonts you import) | `~/.local/share/com.bongbetic.voiced/` | `%LOCALAPPDATA%\com.bongbetic.voiced\` |
|
||||
|
||||
On Linux `XDG_DATA_HOME` moves both. On Windows the archive and imported fonts stay out of the roaming profile so a
|
||||
growing archive and licensed font files are not copied between machines. The window size and position are kept by
|
||||
the window-state plugin in the app config directory (`~/.config/com.bongbetic.voiced/` on Linux).
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Settings, Data tab.
|
||||
|
||||
- **Backup now** writes a zip: a consistent snapshot of `voiced.db`, `assets/`, `archive/`, `fonts/` and a
|
||||
`manifest.json` with a sha256 for every file.
|
||||
- **Automatic backup.** Once per calendar day, while the app is open, Voiced writes
|
||||
`backups/auto/voiced-auto-YYYYMMDD.zip` and keeps the newest 14.
|
||||
- **Restore** validates the zip (manifest, hashes, database integrity check) and stages it. Nothing live changes until
|
||||
the next start: use the "Restart" button or close and reopen the app. On that start the current database, assets,
|
||||
archive and fonts are moved to `backups/pre-restore-<timestamp>/` (a safety copy that is never pruned), then the
|
||||
restored files are moved in. If anything fails the app rolls back and starts on the old data. A backup from an
|
||||
older schema is migrated after restoring.
|
||||
- **Backups contain secrets.** The ERPNext API secret is stored in the SQLite database, so every backup (manual,
|
||||
automatic and pre-restore) holds it in plain form, together with your clients, invoices and bank details. Keep
|
||||
backup files on storage you trust, do not email or upload them casually, and rotate the ERPNext API key if a
|
||||
backup is lost.
|
||||
|
||||
## Fonts and licences
|
||||
|
||||
Eleven families are bundled as static TTF files in `public/fonts/<family>/`, each with its `OFL.txt`: DM Sans,
|
||||
IBM Plex Mono, IBM Plex Sans, Inter, Jost, Lustria, Montserrat, Noto Sans Devanagari, Noto Sans Kannada, Open Sans
|
||||
and Poppins. All are under the SIL Open Font License 1.1. `public/fonts/manifest.json` lists every file with its
|
||||
hash and metrics, and `npm run fonts:verify` checks it. PDFs embed subsets, so the app never depends on system
|
||||
fonts.
|
||||
|
||||
The Void package also installs these fonts system-wide under `/usr/share/fonts/voiced/<family>/` and each OFL text
|
||||
as `/usr/share/licenses/voiced/<family-folder>-OFL.txt`.
|
||||
|
||||
**Now, Gotham and Open Sauce One** are not bundled. Voiced draws Now with Jost, Gotham with Montserrat and Open
|
||||
Sauce One with Poppins (Marble, Citrus Split and Purple Pop use these faces). If you hold a licence
|
||||
for the real fonts, import the files under Settings, Fonts (TTF, OTF or WOFF, one file per weight; WOFF2, variable
|
||||
and font-collection files are refused). Importing is your confirmation that your licence covers this use. The files
|
||||
are stored on this computer only, are included in backups, and issued invoices keep the fonts they were issued with.
|
||||
|
||||
## ERPNext integration
|
||||
|
||||
Settings, Integrations. Verified live against ERPNext v15.121.6 (Frappe 15.121), with and without India Compliance
|
||||
15.32. v14 and v16 have not been tested.
|
||||
|
||||
1. **Integration user.** In ERPNext create a dedicated user (type System User) with the roles **Accounts User** and
|
||||
**Sales User**. Open the user, Settings tab, API Access, Generate Keys, and copy the secret once. Accounts User
|
||||
covers invoices, payments and attachments; Sales User covers customers and addresses (Accounts User alone gets
|
||||
HTTP 403 on Customer). Do not use Administrator or System Manager keys. If you change roles, run
|
||||
`bench clear-cache`.
|
||||
2. **Rounding.** In ERPNext System Settings set Rounding Method to **Commercial Rounding**. ERPNext defaults to
|
||||
Banker's Rounding while Voiced rounds half-paise up, so some totals would differ by 1 paise. Voiced detects a
|
||||
mismatch after creating the draft, keeps it as a draft, never submits it, and names this fix.
|
||||
3. **Connect.** Enter the site address, API key and secret and run "Test connection". Use `https://` for real
|
||||
servers; plain `http://` is accepted only for `localhost`, `127.0.0.1`, `*.localhost` and `*.test`. The secret is
|
||||
never shown again, and it is not sent to a different server than the one it was saved for.
|
||||
4. **Company, accounts and units.** Pick the company, the output tax accounts (CGST, SGST, IGST), the bank account for
|
||||
payments and the unit-of-measure mapping from the loaded option lists.
|
||||
5. **Naming.** *Mirror* (default) names the ERPNext document with the Voiced number, which needs a site with API v2
|
||||
naming (Frappe 15.73 or newer). *Series* lets an ERPNext naming series name the document and puts the Voiced
|
||||
number in the remarks.
|
||||
6. **India Compliance.** Detected by the connection test. It requires a 6 or 8 digit HSN/SAC code on each row
|
||||
(checked at submit), limits document names to 16 characters (mirror mode checks this before posting), and Voiced
|
||||
always sends `is_reverse_charge` as 0 with a warning.
|
||||
|
||||
Not covered: overseas and SEZ customers, multi-currency, reverse charge booked on RCM accounts, e-invoice and
|
||||
e-waybill, TLS-terminating proxies beyond plain HTTPS, and Windows. The live test setup is in
|
||||
`scripts/erpnext-e2e/README.md`.
|
||||
|
||||
## Building for Windows (NSIS, cross-compiled from Linux)
|
||||
|
||||
Tauri supports only the MSVC target. [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin) cross-compiles it
|
||||
and Tauri produces an **NSIS installer** (a `.msi` needs a real Windows host).
|
||||
|
||||
```bash
|
||||
# one-time setup
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
cargo install --locked cargo-xwin
|
||||
# plus NSIS (makensis), LLVM/LLD and clang on the host
|
||||
# plus makensis (NSIS), LLVM/LLD and clang on the host
|
||||
|
||||
npm run app:build:windows
|
||||
npm run app:build:windows # embeds the small WebView2 bootstrapper (needs internet if WebView2 is missing)
|
||||
npm run app:build:windows:offline # embeds the full WebView2 runtime (over 100 MB larger), for offline machines
|
||||
```
|
||||
|
||||
Output lands in `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/` and the
|
||||
portable binary in the sibling `release/` folder.
|
||||
Output: `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/Voiced_<version>_x64-setup.exe`, and the portable
|
||||
binary in the sibling `release/` folder. The installer is a per-user install (`installMode: currentUser`). The
|
||||
frontend, fonts and pdf.js are compiled into the executable. Size budget: about 25 MB for the standard installer.
|
||||
`cargo tree -i aws-lc-sys --target x86_64-pc-windows-msvc` must print nothing (the TLS stack is rustls with the ring
|
||||
provider only, which cross-compiles cleanly).
|
||||
|
||||
### Notes for Linux hosts without an `nsis` package
|
||||
Hosts without an `nsis` package (for example Void Linux):
|
||||
|
||||
`makensis` is required for the installer, and Tauri also fetches a small NSIS plugin. On
|
||||
distributions that don't package NSIS (e.g. Void Linux):
|
||||
|
||||
1. Provide `makensis` (e.g. extract it from your distribution's or an upstream package) and
|
||||
put it on `PATH`.
|
||||
2. Point `makensis` at a full NSIS data directory (3.11 recommended, for `Win/RestartManager.nsh`);
|
||||
it looks in the directory compiled into the binary unless `NSISDIR` is set.
|
||||
1. Provide `makensis` and put it on `PATH`.
|
||||
2. Point it at a full NSIS data directory (3.11 recommended, for `Win/RestartManager.nsh`) with `NSISDIR`.
|
||||
3. Pre-seed Tauri's plugin cache so it does not have to download it:
|
||||
|
||||
```
|
||||
@@ -87,13 +205,11 @@ distributions that don't package NSIS (e.g. Void Linux):
|
||||
# from https://github.com/tauri-apps/nsis-tauri-utils/releases
|
||||
```
|
||||
|
||||
With those in place, `npm run app:build:windows` produces the NSIS `-setup.exe` and the
|
||||
portable `voiced.exe`.
|
||||
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
|
||||
|
||||
Two ways to get a native `.xbps` package.
|
||||
|
||||
### Prebuilt binary package (no xbps-src needed)
|
||||
|
||||
```bash
|
||||
@@ -102,52 +218,97 @@ echo "repository=$(pwd)/build/void/repo" | sudo tee /etc/xbps.d/99-voiced-local.
|
||||
sudo xbps-install -Sy voiced
|
||||
```
|
||||
|
||||
Launch **Voiced** from the application menu, or run `voiced`. Remove it with
|
||||
`sudo xbps-remove voiced`.
|
||||
The script runs `npx tauri build --no-bundle --features custom-protocol` (using your existing `node_modules`), stages
|
||||
the install tree and runs `xbps-create` and `xbps-rindex`. Launch **Voiced** from the application menu or run
|
||||
`voiced`; remove it with `sudo xbps-remove voiced`.
|
||||
|
||||
The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on
|
||||
`libwebkit2gtk41`.
|
||||
|
||||
**Fonts on Void.** The package also installs the bundled TTF fonts under
|
||||
`/usr/share/fonts/voiced/<family>/` and each family's OFL licence under
|
||||
`/usr/share/licenses/voiced/`. The `voiced/` subdirectory avoids file conflicts with Void's own
|
||||
`font-ibm-plex-ttf` and `noto-fonts-ttf`. The `INSTALL` and `REMOVE` scripts in `packaging/void/`
|
||||
run `fc-cache -f /usr/share/fonts/voiced` after install and removal. `xbps-create` packs them from
|
||||
the destdir root (checked with xbps 0.60.7).
|
||||
The package installs `/usr/bin/voiced`, a desktop entry, hicolor icons, the bundled fonts under
|
||||
`/usr/share/fonts/voiced/<family>/`, the OFL texts under `/usr/share/licenses/voiced/`, and `INSTALL` and `REMOVE`
|
||||
action scripts that run `fc-cache -f /usr/share/fonts/voiced` after install and removal. The `voiced/` font
|
||||
subdirectory avoids file conflicts with Void's `font-ibm-plex-ttf` and `noto-fonts-ttf`. It depends on
|
||||
`libwebkit2gtk41>=2.50`, `fontconfig`, `xdg-utils`, `hicolor-icon-theme` and `desktop-file-utils`.
|
||||
|
||||
### Building through xbps-src
|
||||
|
||||
Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way:
|
||||
|
||||
```bash
|
||||
mkdir -p void-packages/srcpkgs/voiced
|
||||
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
|
||||
cd void-packages && ./xbps-src pkg voiced
|
||||
```
|
||||
|
||||
See the comments at the top of the template about the private source archive and checksums.
|
||||
|
||||
## Where data lives
|
||||
|
||||
| What | Location |
|
||||
| ------------------- | ------------------------------------------------------------- |
|
||||
| Database | `<app-data>/voiced.db` — on Windows `%APPDATA%\com.bongbetic.voiced\` |
|
||||
| Logos & signatures | `<app-data>/assets/` |
|
||||
`INSTALL` and `REMOVE` must sit next to the template; xbps-src packs them. The source is a tagged archive of the
|
||||
repository (see the comments at the top of the template; `checksum=SKIP` until a tarball is published).
|
||||
xbps-src builds in a network-less chroot, so vendor the dependencies first: run `cargo vendor` in `src-tauri/` (with a
|
||||
`.cargo/config.toml` pointing at `vendor/`) and populate the npm cache, and include the results in the source
|
||||
archive. The template runs `npm ci` (not `--omit=optional`: that removes
|
||||
the native rollup and esbuild binaries the Vite build needs, and `@napi-rs/canvas` is type-checked by `tsc`).
|
||||
No OpenSSL is needed (rustls), and the
|
||||
dependencies of the ERPNext and backup code (`reqwest`, `zip`) are pure Rust.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/ React + Carbon frontend
|
||||
views/ Onboarding · NewInvoice · InvoiceHistory · SeriesSettings · AppSettings
|
||||
components/ AppShell · InvoicePreview · ImagePicker
|
||||
pdf/ InvoiceDocument (react-pdf template)
|
||||
lib/ api · types · invoice · numberToWords · validators · format · pdf
|
||||
src-tauri/ Rust backend (Tauri commands, SQLite schema, asset store)
|
||||
public/brand/ Bongbetic + vendor brand assets
|
||||
public/fonts/ IBM Plex (woff/woff2)
|
||||
packaging/void/ Void Linux packaging (xbps-src template + build script + desktop entry)
|
||||
views/ Onboarding, NewInvoice, InvoiceHistory, InvoiceDetail, Clients, AppSettings, SeriesSettings
|
||||
components/ AppShell, TemplatePicker, PageSetupControls, PdfPreview, LogoBranding,
|
||||
FontOverrideTable, DataBackupPanel, ErpnextSettingsPanel, RecordPaymentModal, ...
|
||||
hooks/ Export, PDF preview, user fonts, shortcuts
|
||||
lib/ Typed Tauri wrapper (api.ts), money, GST helpers, exportFlow, historyExport,
|
||||
invoiceExportV1, ERPNext UI helpers
|
||||
pdf/ PDF engine
|
||||
model/ buildRenderModel (invoice -> render model)
|
||||
engine/ geometry (A4/Letter, margins), logo slots, layout fingerprint
|
||||
templates/ <family>/ with tokens.ts, plan.ts, Layout.tsx; registry.ts, catalog.ts, contract.ts
|
||||
blocks/ decor/ Shared layout blocks and decor
|
||||
fonts/ Font registration, roles, glyph priming
|
||||
render/ renderCore, Web Worker, latest-wins client
|
||||
flatten.ts assemble.ts Rasterise at 300 DPI and rebuild an image-only PDF
|
||||
testing/ Goldens (layout fingerprints), self-test fixtures, synthetic sample logo
|
||||
src-tauri/src/ Rust backend
|
||||
db.rs Schema as numbered migrations (copies the DB aside before migrating)
|
||||
gst.rs Integer-paise money and GST maths
|
||||
logo.rs Logo trimming, quantising, measuring
|
||||
commands/ Tauri commands: invoice, series, clients, payments, presets, settings, assets,
|
||||
archive, fonts, backup, files, raw (binary IPC), erpnext, logo
|
||||
integrations/erpnext/ ERPNext client, mapping, discovery, push
|
||||
selftest.rs Unattended real-webview self-test
|
||||
public/fonts/ Bundled TTF families, OFL texts, manifest.json (plus legacy IBM Plex web fonts)
|
||||
public/templates/ Template thumbnails (rendered from synthetic sample data)
|
||||
docs/ Template specs, voiced.invoice.v1, release checklist
|
||||
scripts/ Font, logo, template, thumbnail and ERPNext end-to-end tooling
|
||||
packaging/void/ xbps-src template, build-xbps.sh, desktop entry, INSTALL and REMOVE scripts
|
||||
```
|
||||
|
||||
## Ownership
|
||||
## Tests and gates
|
||||
|
||||
© Bongbetic. All rights reserved. This is a private application.
|
||||
Before a release (details in `docs/RELEASE.md`): `npm run build`, `npm test`, `npm run fonts:verify`,
|
||||
`cargo test --manifest-path src-tauri/Cargo.toml`, and the real-webview self-test on a debug or installed build:
|
||||
|
||||
```bash
|
||||
npm run tauri -- build --debug --no-bundle --features custom-protocol
|
||||
VOICED_SELFTEST_OUT=/tmp/voiced-e2e/report.json XDG_DATA_HOME=/tmp/voiced-e2e/data \
|
||||
XDG_CONFIG_HOME=/tmp/voiced-e2e/config timeout 180 src-tauri/target/debug/voiced
|
||||
```
|
||||
|
||||
The app is inert unless `VOICED_SELFTEST_OUT` is set; `ok: true` is expected in the report. The same checks run from
|
||||
Settings, Diagnostics.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Windows and WebView2 have only been exercised in a Windows 11 virtual machine, not on physical hardware; the NSIS
|
||||
installer is cross-compiled and unsigned.
|
||||
- The SAC code for the vendor's services and the signature rule still need confirmation from a chartered accountant.
|
||||
- Now, Gotham and Open Sauce One are replaced by free look-alikes unless you import licensed files.
|
||||
- ERPNext: only v15 is verified; HTTP is allowed only for local hosts; see "Not covered" above.
|
||||
- Not implemented (deferred): copies and watermarks, PDF/A, custom margins, user background textures, SVG logo
|
||||
import, CESS, composition and SEZ invoices.
|
||||
|
||||
## Licence and credit
|
||||
|
||||
Voiced is released under the [Apache License 2.0](LICENSE). Copyright 2026 Bongbetic.
|
||||
|
||||
You are free to use, modify, fork and redistribute it. If you do, you must keep the `LICENSE` and `NOTICE` files and
|
||||
the copyright notices, and state your changes; please also credit the original: **Based on Voiced by Bongbetic**.
|
||||
Bundled fonts keep their own SIL Open Font License texts (see "Fonts and licences").
|
||||
|
||||
Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md). Contributions are licensed under the same terms.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Release checklist
|
||||
|
||||
Run from the repository root on a quiet machine, on a branch cut from `main`.
|
||||
|
||||
## 1. Version
|
||||
|
||||
Keep these in step: `package.json` and the root entry of `package-lock.json`, `src-tauri/Cargo.toml` and
|
||||
`src-tauri/Cargo.lock` (`cargo update --offline -p voiced`), `src-tauri/tauri.conf.json`, and `version=` plus
|
||||
`revision=` in `packaging/void/template`. The PDF Creator string follows `package.json` (`src/lib/pdf.tsx`); layout
|
||||
fingerprints do not include it, so goldens stay unchanged.
|
||||
|
||||
## 2. Gates
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm test # about 7 minutes
|
||||
npm run fonts:verify
|
||||
cargo test --manifest-path src-tauri/Cargo.toml
|
||||
cargo tree -i aws-lc-sys --manifest-path src-tauri/Cargo.toml # must not match
|
||||
cargo tree -i aws-lc-sys --manifest-path src-tauri/Cargo.toml --target x86_64-pc-windows-msvc # must not match
|
||||
```
|
||||
|
||||
Real-webview self-test on the debug build:
|
||||
|
||||
```bash
|
||||
npm run tauri -- build --debug --no-bundle --features custom-protocol
|
||||
VOICED_SELFTEST_OUT=/tmp/voiced-e2e/report.json XDG_DATA_HOME=/tmp/voiced-e2e/data \
|
||||
XDG_CONFIG_HOME=/tmp/voiced-e2e/config timeout 180 src-tauri/target/debug/voiced
|
||||
```
|
||||
|
||||
`/tmp/voiced-e2e/report.json` must contain `"ok": true`. Repeat the same check on each installed build with
|
||||
Settings, Diagnostics.
|
||||
|
||||
## 3. Void package
|
||||
|
||||
```bash
|
||||
./packaging/void/build-xbps.sh
|
||||
tar --zstd -tf build/void/repo/voiced-<version>_1.x86_64.xbps | less
|
||||
```
|
||||
|
||||
Expect `./usr/bin/voiced`, the desktop file, hicolor icons, `usr/share/fonts/voiced/<family>/*.ttf`,
|
||||
`usr/share/licenses/voiced/*-OFL.txt`, `./INSTALL` and `./REMOVE`. Then install it into a clean container:
|
||||
|
||||
```bash
|
||||
podman run --rm -v "$PWD/build/void/repo:/repo:Z" ghcr.io/void-linux/void-glibc-full sh -c '
|
||||
xbps-install -Syu xbps && xbps-install -y -R /repo voiced &&
|
||||
fc-list | grep -iE "montserrat|poppins|lustria|dm sans|inter|jost|open sans|ibm plex" &&
|
||||
ls /usr/share/licenses/voiced && ldd /usr/bin/voiced | grep "not found"; xbps-remove -y voiced'
|
||||
```
|
||||
|
||||
`ldd` must print no `not found` line, and `xbps-remove` must finish without errors. Optionally build with
|
||||
`xbps-src` in a `void-packages` checkout (see the README).
|
||||
|
||||
## 4. Windows installer
|
||||
|
||||
```bash
|
||||
npm run app:build:windows # standard installer
|
||||
npm run app:build:windows:offline # only for machines without internet
|
||||
```
|
||||
|
||||
Needs `cargo-xwin`, `makensis` and the NSIS plugin cache (README, "Building for Windows"). Check the size of
|
||||
`src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe` against the budget of about 25 MB. Optional:
|
||||
sign it with `osslsigncode`. Never commit build outputs (`build/`, `dist/`, `src-tauri/target/`).
|
||||
|
||||
## 5. Manual first-run checklist (on a real machine, installed build, not `app:dev`)
|
||||
|
||||
Linux and Windows. Windows has never been run on real hardware, so treat it as untested until this passes.
|
||||
|
||||
- **Onboarding and Branding.** Complete first-run setup; upload a wide and a square logo, check the dark-theme
|
||||
wordmark and the signature.
|
||||
- **Template picker.** Open all 14 templates; switch A4 and Letter and the margin presets; check the logo slots.
|
||||
- **Export.** Issue an invoice; export searchable and flattened; change settings and re-export; "Original as issued"
|
||||
must be byte-identical to the archived PDF. On Windows, check file names with `/`, `:` and reserved names.
|
||||
- **Fonts.** Settings, Fonts: import a TTF for Now, Gotham or Open Sauce One (if a licensed file is at hand), confirm
|
||||
a template that uses it changes, remove it again.
|
||||
- **Clients.** Create a client with structured address and GST category; use it on an invoice.
|
||||
- **Settings tabs.** Business, Branding, Bank and payments, Invoice defaults, Numbering, Templates and export, Fonts,
|
||||
Data, Integrations: open each and save a change.
|
||||
- **History, detail and payments.** Filter the list; open an invoice; record a payment with TDS; cancel one; export
|
||||
CSV, JSON and `voiced.invoice.v1`.
|
||||
- **Data.** Backup now, change something, restore the backup, restart, confirm the data returned and that
|
||||
`backups/pre-restore-*` exists. Confirm the archive and fonts directories (Windows: under `%LOCALAPPDATA%`).
|
||||
- **Integrations.** Test connection and push an invoice to a test ERPNext (README, "ERPNext integration").
|
||||
- **Single instance.** Launch a second copy: the first window must come to the front.
|
||||
|
||||
## 6. Open items
|
||||
|
||||
A chartered accountant's sign-off on the SAC code and the signature rule is still open.
|
||||
@@ -74,7 +74,7 @@ Everything can be built with View backgroundColor and borders. Svg is only neede
|
||||
|
||||
## Mapping to Voiced fields
|
||||
|
||||
- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. public/brand/sample-wordmark-logo.png), hide the text lockup.
|
||||
- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. src/pdf/testing/fixtures/sample-wordmark-logo.png), hide the text lockup.
|
||||
- Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned).
|
||||
- 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'.
|
||||
- Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# voiced.invoice.v1
|
||||
|
||||
A neutral export of issued invoices for software Voiced has no direct integration with. Choose invoices in the
|
||||
History list (checkboxes) or open one, then use "Export selected (voiced.invoice.v1 JSON)" or "(line items CSV)".
|
||||
The code is `src/lib/invoiceExportV1.ts`; `src/lib/__golden__/invoice-v1.golden.json` is a full example.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Money is exact.** Every amount appears twice: `<name>_paise` (an integer) and `<name>` (a decimal string with two
|
||||
places, e.g. `"7310.10"`). Nothing is a float. Rates (percent) are decimal strings such as `"9"` or `"6.25"`.
|
||||
- **Deterministic.** The same invoices give the same bytes: fixed key order, no timestamps, input order kept.
|
||||
- **JSON file** is an array with one object per invoice; each object carries `"schema": "voiced.invoice.v1"`.
|
||||
- Cancelled invoices are included with `"cancelled": true` (their figures are kept; filter them out of sums).
|
||||
- An unregistered supplier's plain "Invoice" has `document_type: "invoice"`, all tax heads zero and no supplier GSTIN.
|
||||
|
||||
## Invoice object
|
||||
|
||||
| Key | Meaning |
|
||||
| --- | --- |
|
||||
| `schema`, `number`, `issue_date`, `due_date` | `YYYY-MM-DD` dates; `due_date` is null when unset |
|
||||
| `document_type` | `tax_invoice` or `invoice` |
|
||||
| `status`, `cancelled`, `cancelled_at`, `cancel_reason` | `status` is `issued` or `cancelled` |
|
||||
| `currency`, `po_number` | always `INR` |
|
||||
| `supplier` | `name`, `address`, `gstin` (null if none), `pan`, `gst_registration`, `state {code, name}`; frozen at issue |
|
||||
| `client` | `name`, `gstin`, `gst_category`, `address {text, line1, line2, city, pincode, state}`, `place_of_supply {code, name}` |
|
||||
| `items[]` | `line`, `description`, `hsn_sac`, `quantity` (decimal string), `unit`, `rate`, `taxable_value` (each with `_paise`) |
|
||||
| `tax` | `cgst`, `sgst`, `utgst`, `igst`, each `{rate, amount_paise, amount}`; heads that do not apply are zero |
|
||||
| `totals` | `subtotal`, `discount`, `taxable_value`, `tax`, `total` (each with `_paise`), `amount_in_words` |
|
||||
| `reverse_charge`, `notes` | |
|
||||
| `payments[]` | `date`, `amount` (cash), `tds`, `mode`, `reference` (amounts with `_paise`) |
|
||||
|
||||
Notes on the fields:
|
||||
|
||||
- **Items.** `taxable_value` of a line is the line amount before the invoice-level discount. `totals.taxable_value` is
|
||||
`subtotal - discount`, the base the tax is charged on. A fixed-amount line has `quantity "1"` and `unit null`.
|
||||
- **UTGST.** When the supplier is in a union territory without a legislature (state codes 04, 26, 31, 35, 38) the second
|
||||
head is reported under `utgst` and `sgst` is zero.
|
||||
- **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured
|
||||
address parts and `gst_category` come from the client's saved record at export time (null or derived from the GSTIN
|
||||
when the invoice has no saved client).
|
||||
- **Supplier snapshot.** Only name, address, GSTIN, PAN, registration and state are exported; logo paths, e-mail and
|
||||
phone are not.
|
||||
|
||||
## Line-items CSV
|
||||
|
||||
One row per line item, UTF-8 with a byte order mark and CRLF line ends; text cells that a spreadsheet would read as a
|
||||
formula get a leading apostrophe. Columns: `invoice_number, issue_date, due_date, document_type, status, cancelled,
|
||||
currency, supplier_name, supplier_gstin, client_name, client_gstin, client_gst_category, place_of_supply_code,
|
||||
place_of_supply, reverse_charge, line, description, hsn_sac, quantity, unit, rate, taxable_value, invoice_discount,
|
||||
invoice_cgst, invoice_sgst, invoice_utgst, invoice_igst, invoice_total`. The `invoice_*` amounts are filled on the first
|
||||
row of each invoice only, so summing a column never counts an invoice twice.
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
// The app talks to its Rust backend through Tauri's `invoke`, which does not exist in a plain
|
||||
// browser. These smoke tests cover the web build in both situations: without the backend (the app
|
||||
// must fail gracefully) and with a minimal stubbed backend (the app shell must render).
|
||||
|
||||
const SETTINGS = {
|
||||
vendorName: "Smoke Test Studio",
|
||||
vendorAddress: "1 Test Street",
|
||||
vendorEmail: "smoke@example.com",
|
||||
vendorPhone: "",
|
||||
vendorPan: "",
|
||||
vendorGstin: "",
|
||||
vendorStateCode: "",
|
||||
logoPath: null,
|
||||
signaturePath: null,
|
||||
defaultBankId: null,
|
||||
defaultTaxRate: 0,
|
||||
defaultTaxType: "none",
|
||||
paymentTermsDays: 15,
|
||||
currency: "INR",
|
||||
onboarded: true,
|
||||
theme: "g10",
|
||||
gstRegistration: "unregistered",
|
||||
defaultHsnSac: "",
|
||||
signatoryName: "",
|
||||
signatoryDesignation: "",
|
||||
renderPrefs: "",
|
||||
logoOriginalPath: null,
|
||||
logoKnockoutPath: null,
|
||||
logoMeta: "",
|
||||
logoIncludesName: "auto",
|
||||
};
|
||||
|
||||
function collectPageErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
test("web build loads without a backend and fails gracefully", async ({ page }) => {
|
||||
const errors = collectPageErrors(page);
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle("Voiced");
|
||||
await expect(page.getByText("Could not start Voiced")).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("app shell renders with a stubbed backend", async ({ page }) => {
|
||||
const errors = collectPageErrors(page);
|
||||
await page.addInitScript((settings) => {
|
||||
const w = window as unknown as { __TAURI_INTERNALS__: unknown };
|
||||
w.__TAURI_INTERNALS__ = {
|
||||
transformCallback: () => 0,
|
||||
invoke: async (cmd: string) => {
|
||||
if (cmd === "get_settings") return settings;
|
||||
if (cmd.startsWith("list_")) return [];
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}, SETTINGS);
|
||||
await page.goto("/");
|
||||
const nav = page.getByRole("navigation", { name: "Voiced navigation" });
|
||||
await expect(nav).toBeVisible();
|
||||
for (const label of ["Clients", "Settings"]) {
|
||||
await expect(nav.getByText(label, { exact: true })).toBeVisible();
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["node_modules/", "dist/", "src-tauri/", "public/", "docs/", "playwright-report/", "test-results/"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
{
|
||||
rules: {
|
||||
// Leading underscore marks an intentionally unused binding (omitting a key via rest, unused
|
||||
// override parameters).
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_", ignoreRestSiblings: true },
|
||||
],
|
||||
// The BOM and NBSP are written literally on purpose in the CSV exporters and the text sanitiser.
|
||||
"no-irregular-whitespace": ["error", { skipStrings: true, skipTemplates: true, skipRegExps: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
// The React Compiler rules shipped in eslint-plugin-react-hooks 7 flag long-standing patterns
|
||||
// here (load data on mount, "latest value" refs written during render). They are legitimate
|
||||
// design feedback but not bugs, and rewriting them blind risks behaviour changes in an
|
||||
// effect-heavy app, so they stay visible as warnings until each site is reworked.
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"react-hooks/set-state-in-effect": "warn",
|
||||
"react-hooks/refs": "warn",
|
||||
"react-hooks/immutability": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Test helper that reads react-pdf's untyped internal layout data.
|
||||
files: ["src/pdf/testing/**/*.ts"],
|
||||
rules: { "@typescript-eslint/no-explicit-any": "warn" },
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
languageOptions: { globals: globals.browser },
|
||||
},
|
||||
{
|
||||
files: ["scripts/**/*.{js,mjs}", "e2e/**/*.ts", "*.config.{js,ts}"],
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
);
|
||||
@@ -6,6 +6,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "voiced",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.2",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
||||
"type": "module",
|
||||
"author": "Bongbetic",
|
||||
@@ -22,7 +23,9 @@
|
||||
"decor:script-word": "node scripts/decor/script-word.mjs",
|
||||
"decor:icons": "node scripts/decor/icons.mjs",
|
||||
"thumbnails": "node scripts/thumbnails.mjs",
|
||||
"templates:compare": "node scripts/templates/compare.mjs"
|
||||
"templates:compare": "node scripts/templates/compare.mjs",
|
||||
"lint": "eslint .",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@carbon/icons-react": "^11.89.0",
|
||||
@@ -39,15 +42,21 @@
|
||||
"react-is": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tauri-apps/cli": "^2.12.1",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"clipper-lib": "^6.4.2",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "17.13.0",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"sass": "^1.105.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "8.71.0",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^5.0.3"
|
||||
},
|
||||
|
||||
@@ -89,7 +89,7 @@ mkdir -p "$REPO"
|
||||
-n "$PKGVER" \
|
||||
-s "Offline invoicing app by Bongbetic" \
|
||||
-S "Voiced is an offline-first invoice generator with automatic numbering, GST support, optional signatures and print-ready PDF export." \
|
||||
-l "proprietary" \
|
||||
-l "Apache-2.0" \
|
||||
-H "https://git.bongbetic.com/xavierk/Voiced" \
|
||||
-m "Xavier Karma <xavier@bongbetic.com>" \
|
||||
-D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
|
||||
@@ -101,7 +101,9 @@ mkdir -p "$REPO"
|
||||
mv -f "$OUT"/${PKGVER}.*.xbps "$REPO/"
|
||||
|
||||
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 "Package: $REPO/${PKGVER}.${ARCH}.xbps"
|
||||
|
||||
@@ -21,11 +21,14 @@
|
||||
# not install them. Copy them next to the template (see the commands above).
|
||||
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
|
||||
# unless mkfontdir and mkfontscale are installed, hence these scripts.
|
||||
# * If the build machine is offline, pre-populate the npm cache and Cargo
|
||||
# registry in the masterdir before building.
|
||||
# * xbps-src builds without network access: vendor the crates (`cargo vendor`
|
||||
# in src-tauri/ plus a .cargo/config.toml pointing at vendor/) and pre-populate
|
||||
# the npm cache in the source archive or masterdir before building.
|
||||
# * No openssl-devel: the ERPNext client uses rustls (ring); reqwest and zip are
|
||||
# pure Rust and need no extra system libraries.
|
||||
#
|
||||
pkgname=voiced
|
||||
version=0.1.0
|
||||
version=1.0.2
|
||||
revision=1
|
||||
archs="x86_64*"
|
||||
hostmakedepends="rust nodejs pkg-config"
|
||||
@@ -33,7 +36,7 @@ makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel"
|
||||
depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
|
||||
short_desc="Offline invoicing app by Bongbetic"
|
||||
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
||||
license="custom:proprietary"
|
||||
license="Apache-2.0"
|
||||
homepage="https://git.bongbetic.com/xavierk/Voiced"
|
||||
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
|
||||
checksum=SKIP
|
||||
@@ -43,7 +46,10 @@ compression="zstd"
|
||||
font_dirs="/usr/share/fonts/voiced"
|
||||
|
||||
do_build() {
|
||||
npm install --include=dev --no-audit --no-fund
|
||||
# Not --omit=optional: that also drops rollup's and esbuild's native binaries (they are
|
||||
# optional dependencies), so vite fails; and tsc in `npm run build` type-checks the
|
||||
# @napi-rs/canvas test helpers, so that package must be installed too.
|
||||
npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
cd src-tauri
|
||||
# `custom-protocol` embeds the frontend for production; without it the app
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const PORT = 4173;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: `http://localhost:${PORT}`,
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
// Serves the production build (run `npm run build` first).
|
||||
webServer: {
|
||||
command: `npx vite preview --port ${PORT} --strictPort`,
|
||||
url: `http://localhost:${PORT}`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -52,7 +52,6 @@ function wordCommands(font) {
|
||||
function rotate(cmds) {
|
||||
const pts = cmds.flatMap((c) => c.args);
|
||||
const xs = pts.filter((_, i) => i % 2 === 0);
|
||||
const ys = pts.filter((_, i) => i % 2 === 1);
|
||||
const inkLen = Math.max(...xs) - Math.min(...xs);
|
||||
const s = INK_LENGTH / inkLen;
|
||||
const map = (x, y) => [-y * s, -x * s];
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# frappe/erpnext v15 plus the india_compliance app (installed per site, not globally)
|
||||
FROM docker.io/frappe/erpnext:v15.121.6
|
||||
USER frappe
|
||||
RUN bench get-app --skip-assets --branch version-15 https://github.com/resilient-tech/india-compliance.git \
|
||||
&& ls -1 apps > sites/apps.txt || true
|
||||
@@ -0,0 +1,148 @@
|
||||
# ERPNext live end-to-end (podman)
|
||||
|
||||
Runs Voiced's ERPNext integration against real ERPNext v15 sites: one plain site and one with India Compliance.
|
||||
Everything is rootless podman (`podman` and `podman-compose`, never docker). All names are prefixed `voiced-erp`.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `compose.yml` | Lean stack: MariaDB, one Redis, backend (gunicorn on `127.0.0.1:8088`), one worker. Project name `voiced-erp`. |
|
||||
| `Containerfile.ic` | `frappe/erpnext:v15.121.6` plus the `india_compliance` app files (`version-15` branch). |
|
||||
| `setup.sh` | Builds the image, starts the stack, creates both sites, prepares them, writes `.env.a` and `.env.b`. |
|
||||
| `setup_site.py` | Runs inside the container: company, accounts, address, items, integration user and keys. |
|
||||
| `../../src-tauri/src/integrations/erpnext/live_tests.rs` | The `#[ignore]` Rust tests. |
|
||||
|
||||
## Reproduce
|
||||
|
||||
```bash
|
||||
cd scripts/erpnext-e2e
|
||||
./setup.sh # first run pulls ~2 GB of images and takes about 10 minutes
|
||||
```
|
||||
|
||||
`setup.sh` creates two sites that one backend serves by Host header:
|
||||
|
||||
| Site | URL | Contents | Env file |
|
||||
|---|---|---|---|
|
||||
| `127.0.0.1` | `http://127.0.0.1:8088` | ERPNext only | `.env.a` |
|
||||
| `localhost` | `http://localhost:8088` | ERPNext + India Compliance | `.env.b` |
|
||||
|
||||
Both get the company "Voiced Test Co" (abbreviation `VTC`, INR, India, fiscal year 2026-27), the output tax accounts
|
||||
`Output Tax CGST|SGST|UTGST|IGST - VTC` (India Compliance creates its own with the same names), `TDS Receivable - VTC`,
|
||||
the bank account `HDFC - VTC`, a company address, the UOMs Hour/Minute/Second, the items `VOICED-SERVICE` (stock UOM Nos)
|
||||
and `VOICED-HOURLY` (stock UOM Hour), and an integration user with a fresh API key and secret.
|
||||
`.env.*` files hold those keys; they are git-ignored (`.env.*` in the root `.gitignore`) and must never be committed.
|
||||
|
||||
Run the Rust tests (they only touch the site named by the sourced env file):
|
||||
|
||||
```bash
|
||||
set -a; . scripts/erpnext-e2e/.env.a; set +a # or .env.b for the India Compliance site
|
||||
cargo test --manifest-path src-tauri/Cargo.toml live_ -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
Environment variables the tests read:
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `ERPNEXT_URL`, `ERPNEXT_KEY`, `ERPNEXT_SECRET` | Site address and the integration user's API key and secret. |
|
||||
| `ERPNEXT_COMPANY` (`Voiced Test Co`), `ERPNEXT_ABBR` (`VTC`) | Company; account names derive from the abbreviation. |
|
||||
| `ERPNEXT_IC` | `1` on the India Compliance site; the `live_ic_*` tests skip themselves otherwise. |
|
||||
| `ERPNEXT_ROUNDING` | `banker` (ERPNext default) or `commercial`; decides what the half-paise test expects. |
|
||||
| `ERPNEXT_BANK_ACCOUNT` (`HDFC - VTC`) | Account that receives payments. |
|
||||
|
||||
Each test builds its own in-memory Voiced database and a fresh invoice series (`L<time><n>/001`), so reruns never collide
|
||||
with documents from earlier runs. Switch the rounding method of a site with
|
||||
`podman exec -i voiced-erp-backend bash -c "cd /home/frappe/frappe-bench/sites && /home/frappe/frappe-bench/env/bin/python - 127.0.0.1 rounding=commercial" < setup_site.py`
|
||||
(re-run `setup.sh` or copy the printed `ERPNEXT_*` lines if you need the new key; the key is regenerated on every run).
|
||||
|
||||
Tear down (touches only the `voiced-erp` project; the cached images stay):
|
||||
|
||||
```bash
|
||||
podman-compose -p voiced-erp -f compose.yml down -v
|
||||
podman volume rm voiced-erp_voiced-erp-db voiced-erp_voiced-erp-sites voiced-erp_voiced-erp-logs # only if `down -v` left them
|
||||
```
|
||||
|
||||
Notes on rootless podman: the compose file has no healthchecks (podman's healthcheck timers need systemd), and the
|
||||
backend and worker are started by `setup.sh` after the configurator has exited.
|
||||
|
||||
## Integration user (feeds the main README)
|
||||
|
||||
Create a dedicated user in ERPNext (User list, type System User) with **the roles "Accounts User" and "Sales User"**.
|
||||
Then open the user, "Settings" tab, "API Access", "Generate Keys", and copy the secret once.
|
||||
|
||||
Why both (verified live on plain ERPNext and with India Compliance; `setup_site.py` creates exactly this user):
|
||||
|
||||
- "Accounts User": create and submit Sales Invoices (draft, insert-and-submit, submit-later) and Payment Entries; read
|
||||
Accounts, Items, UOMs, Cost Centers, Price Lists, Item Groups, Company, the Sales Invoice naming series and (with
|
||||
India Compliance) GST Settings; attach a private PDF to the invoice.
|
||||
- "Sales User": look up and create Customers and Addresses, read Customer Groups, Territories and the tax templates.
|
||||
With "Accounts User" alone a fresh site answers HTTP 403 on Customer.
|
||||
|
||||
No tax template is used (Voiced always sends explicit tax rows), so the tax-template list is only a convenience.
|
||||
Do not use Administrator or System Manager keys. Roles on a user are cached by Frappe: after changing them run
|
||||
`bench clear-cache` or the next request may still use the old set.
|
||||
|
||||
## Results (what was verified live)
|
||||
|
||||
Versions: Frappe 15.121.3, ERPNext 15.121.6, India Compliance 15.32.0 (the `version-15` branch; commit `db73b3e` at the time of this run, so a later image build may differ), MariaDB 11.8,
|
||||
Redis 8.6, API v2 naming available (Frappe 15.73 or newer).
|
||||
|
||||
Verified on both sites unless marked IC (India Compliance site only):
|
||||
|
||||
- Connection test: user, frappe/erpnext/india_compliance versions, India Compliance detection, no warnings when the
|
||||
accounts match. Option lists load (companies, addresses, accounts, UOMs, cost centers, groups, series).
|
||||
- Sales Invoice push in mirror mode (API v2, `name` = Voiced number): CGST+SGST, IGST, discount (`apply_discount_on:
|
||||
Net Total`), fractional hours, repeated descriptions on code-less rows, item-code rows from a preset, remarks. The
|
||||
grand total, the tax amounts per head and the posting date equal Voiced's; `disable_rounded_total` works.
|
||||
- PDF attachment: uploaded through `upload_file`, `is_private` is 1.
|
||||
- Idempotency: a second push is a no-op; with the local sync row deleted, the 409 path adopts the existing document
|
||||
(no duplicate, no second PDF).
|
||||
- Submit: insert then submit in a second push (v2 `method/submit`), and series mode through the v1 `run_method` route.
|
||||
- Series mode: ERPNext names the document, the Voiced number is in `remarks`, and a lost sync row is recovered by the
|
||||
remarks lookup.
|
||||
- Payments: `get_payment_entry` plus overrides. `bank_account` is the Account name and lands in `paid_to`;
|
||||
`paid_amount = received_amount = cash`; the reference's `allocated_amount = cash + TDS`; TDS is one `deductions` row
|
||||
`{account, cost_center, amount: +TDS}`; the invoice's outstanding falls by cash + TDS; partial payments leave the rest.
|
||||
- Customer and Address shape (IC: `gstin`, `gst_category` on both; state exact name; link to the Customer).
|
||||
- IC: `place_of_supply` as `29-Karnataka`; Voiced's 38 state names equal India Compliance's `gst_state` list; HSN/SAC
|
||||
must be 6 or 8 digits (GST Settings `min_hsn_digits` was 6) and is enforced at submit; the 16-character name rule;
|
||||
GST-account warnings (mismatching CGST account, company without GST accounts, a too-long next number).
|
||||
|
||||
Defects found and fixed (each has a mock-based test in the normal suite):
|
||||
|
||||
1. `load_options`: the company-address list asked for the India Compliance-only `gstin` field and failed with HTTP 417
|
||||
on a plain site. It now retries without it.
|
||||
2. Code-less rows with a fractional quantity of hours/minutes failed ("Quantity cannot be a fraction ... UOM Nos"):
|
||||
ERPNext defaults the row's stock UOM to Nos without an item. Such rows now also send `stock_uom` = the row UOM.
|
||||
3. Rounding: ERPNext's default Rounding Method is Banker's Rounding; Voiced rounds half-paise up. On ties (for example
|
||||
9% of 10.50) the totals differ by 1 paise. A created document whose total differs is now a `conflict`: kept as a
|
||||
draft, never submitted, no PDF, with a message naming the fix (System Settings, Rounding Method, "Commercial
|
||||
Rounding"). The existing-document conflict message carries the same hint. With Commercial Rounding every case matched.
|
||||
4. A lost link to a client's address created a new address on every first push. The customer's matching address (same
|
||||
first line and PIN) is reused. (`creation` in the order-by is ambiguous with a Dynamic Link filter and gives HTTP 500.)
|
||||
5. A re-adopted document got a second copy of the PDF. A file of the same name is now looked up first.
|
||||
6. IC refuses `is_reverse_charge = 1` unless the tax rows are negative amounts on separate "RCM" accounts, which would
|
||||
make ERPNext's total differ from Voiced's. It is sent as 0 and the push warns.
|
||||
7. IC's 16-character rule is now checked before the POST in mirror mode (a readable, local refusal that points to series
|
||||
naming); ERPNext's own message is "Transaction Name must be 16 characters or fewer to meet GST requirements".
|
||||
8. A TDS-only payment (no cash) got ERPNext's bare "Paid Amount is mandatory"; it is refused locally with the reason.
|
||||
9. A fractional quantity on a whole-number UOM is refused by ERPNext with a readable message; Voiced appends what to
|
||||
change (unit mapping). No local pre-check: whether "Nos" is whole-number is a site setting.
|
||||
|
||||
Limits and notes:
|
||||
|
||||
- Rounding setting cannot be read by a restricted user, so it is detected by comparing the totals after creating the
|
||||
draft, not in the connection test.
|
||||
- With India Compliance, an invoice with a missing or short HSN/SAC is created as a draft and fails at submit; the
|
||||
readable ERPNext message ("HSN/SAC must exist and should be 6 or 8 digits long for the following row numbers") is
|
||||
shown.
|
||||
- A mirrored item row with an item code whose stock UOM differs from the row UOM works for whole quantities
|
||||
(`conversion_factor` 1); a fractional quantity needs the item's stock UOM to allow fractions.
|
||||
- One intermittent failure was seen once in the half-paise test on the India Compliance site (the push of a non-tie
|
||||
case failed; three later full runs and a single-test run passed). The cause was not captured.
|
||||
|
||||
Not verified:
|
||||
|
||||
- ERPNext v14 and v16 (the v16 images that exist on some machines were not used), other India Compliance versions.
|
||||
- Windows and WebView2; the Tauri commands and UI (the tests call the Rust push/payment functions directly).
|
||||
- Overseas/SEZ customers, UTGST supplier states, multi-currency, GST Settings with "Round Off GST Values" on,
|
||||
reverse charge booked on RCM accounts, e-invoice/e-waybill (India Compliance API features), a TLS or reverse-proxied
|
||||
site (only plain `http://` on a loopback address), Frappe Cloud or any site with `allow_cors` or rate limits.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Lean ERPNext v15 stack for Voiced's live integration tests (project name: voiced-erp).
|
||||
# db + redis + backend (gunicorn, published on 127.0.0.1:8088) + one worker. No nginx/websocket/scheduler.
|
||||
# setup.sh starts it in order (db, redis, configurator, then backend and worker); do not `up` it blindly.
|
||||
# Image: voiced-erp-ic:v15 = frappe/erpnext v15 + the india_compliance app files (see Containerfile.ic).
|
||||
name: voiced-erp
|
||||
services:
|
||||
db:
|
||||
image: docker.io/library/mariadb:11.8
|
||||
container_name: voiced-erp-db
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
- --skip-character-set-client-handshake
|
||||
- --innodb-buffer-pool-size=256M
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: admin
|
||||
volumes:
|
||||
- voiced-erp-db:/var/lib/mysql
|
||||
|
||||
redis:
|
||||
image: docker.io/library/redis:8.6-alpine
|
||||
container_name: voiced-erp-redis
|
||||
|
||||
configurator:
|
||||
image: localhost/voiced-erp-ic:v15
|
||||
container_name: voiced-erp-configurator
|
||||
restart: "no"
|
||||
entrypoint: ["bash", "-c"]
|
||||
command:
|
||||
- >
|
||||
wait-for-it -t 120 db:3306;
|
||||
ls -1 apps > sites/apps.txt;
|
||||
bench set-config -g db_host db;
|
||||
bench set-config -gp db_port 3306;
|
||||
bench set-config -g redis_cache redis://redis:6379;
|
||||
bench set-config -g redis_queue redis://redis:6379;
|
||||
bench set-config -g redis_socketio redis://redis:6379;
|
||||
bench set-config -gp socketio_port 9000;
|
||||
volumes:
|
||||
- voiced-erp-sites:/home/frappe/frappe-bench/sites
|
||||
- voiced-erp-logs:/home/frappe/frappe-bench/logs
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
backend:
|
||||
image: localhost/voiced-erp-ic:v15
|
||||
container_name: voiced-erp-backend
|
||||
ports:
|
||||
- "127.0.0.1:8088:8000"
|
||||
volumes:
|
||||
- voiced-erp-sites:/home/frappe/frappe-bench/sites
|
||||
- voiced-erp-logs:/home/frappe/frappe-bench/logs
|
||||
|
||||
worker:
|
||||
image: localhost/voiced-erp-ic:v15
|
||||
container_name: voiced-erp-worker
|
||||
command: ["bench", "worker", "--queue", "short,default,long"]
|
||||
volumes:
|
||||
- voiced-erp-sites:/home/frappe/frappe-bench/sites
|
||||
- voiced-erp-logs:/home/frappe/frappe-bench/logs
|
||||
|
||||
volumes:
|
||||
voiced-erp-db:
|
||||
voiced-erp-sites:
|
||||
voiced-erp-logs:
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Brings up the voiced-erp podman project and prepares two ERPNext v15 sites for the live tests:
|
||||
# 127.0.0.1 plain ERPNext -> .env.a (URL http://127.0.0.1:8088)
|
||||
# localhost ERPNext + India Compliance -> .env.b (URL http://localhost:8088)
|
||||
# Both are served by the same backend; Frappe picks the site from the Host header.
|
||||
# Safe to re-run: existing sites are kept and the integration user's key is regenerated.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
COMPOSE=(podman-compose -p voiced-erp -f compose.yml)
|
||||
IMAGE=localhost/voiced-erp-ic:v15
|
||||
BENCH_SITES=/home/frappe/frappe-bench/sites
|
||||
|
||||
podman image exists "$IMAGE" || podman build -t "$IMAGE" -f Containerfile.ic .
|
||||
|
||||
"${COMPOSE[@]}" up -d db redis configurator
|
||||
until [ "$(podman inspect -f '{{.State.Status}}' voiced-erp-configurator)" = exited ]; do sleep 2; done
|
||||
[ "$(podman inspect -f '{{.State.ExitCode}}' voiced-erp-configurator)" = 0 ] || { echo "configurator failed: podman logs voiced-erp-configurator" >&2; exit 1; }
|
||||
"${COMPOSE[@]}" up -d backend worker
|
||||
|
||||
in_backend() { podman exec -i voiced-erp-backend bash -c "$1"; }
|
||||
|
||||
for site in 127.0.0.1 localhost; do
|
||||
default=""; [ "$site" = 127.0.0.1 ] && default="--set-default"
|
||||
in_backend "test -d $BENCH_SITES/$site || bench new-site $site --mariadb-user-host-login-scope='%' \
|
||||
--admin-password=admin --db-root-username=root --db-root-password=admin --install-app erpnext $default" >/dev/null
|
||||
done
|
||||
|
||||
until curl -fsS http://127.0.0.1:8088/api/method/ping >/dev/null 2>&1; do sleep 2; done
|
||||
|
||||
# setup_site.py prints `ENV KEY=VALUE` lines; turn them into a sourceable env file (untracked: .env.* is ignored).
|
||||
write_env() { # site, url, file, extra args...
|
||||
local site=$1 url=$2 file=$3; shift 3
|
||||
in_backend "cd $BENCH_SITES && /home/frappe/frappe-bench/env/bin/python - $site $*" < setup_site.py \
|
||||
| sed -n 's/^ENV \([A-Z_]*\)=\(.*\)$/export \1="\2"/p' > "$file"
|
||||
echo "export ERPNEXT_URL=\"$url\"" >> "$file"
|
||||
chmod 600 "$file"
|
||||
}
|
||||
write_env 127.0.0.1 http://127.0.0.1:8088 .env.a
|
||||
write_env localhost http://localhost:8088 .env.b ic
|
||||
|
||||
echo "Ready. Source .env.a (plain) or .env.b (India Compliance), then run the live tests (see README.md)."
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Prepare one ERPNext site for Voiced's live integration tests.
|
||||
|
||||
Run inside the backend container (see README.md):
|
||||
podman exec -i voiced-erp-backend python - <site> [ic] [rounding=commercial] < setup_site.py
|
||||
|
||||
Creates (idempotently): the Indian company "Voiced Test Co" (abbr VTC, INR), the fiscal year, GST output tax
|
||||
accounts, a bank account, a company address, an integration user with API keys, and a few Items/UOMs.
|
||||
Prints one line `ENV KEY=VALUE` per value the Rust live tests need. Never run against a real site.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
import frappe
|
||||
|
||||
SITE = sys.argv[1]
|
||||
IC = "ic" in sys.argv[2:]
|
||||
# `rounding=commercial` switches System Settings > Rounding Method (the ERPNext default is Banker's Rounding).
|
||||
ROUNDING = {"commercial": "Commercial Rounding", "banker": "Banker's Rounding"}.get(
|
||||
next((a.split("=", 1)[1] for a in sys.argv[2:] if a.startswith("rounding=")), ""), None
|
||||
)
|
||||
ABBR = "VTC"
|
||||
COMPANY = "Voiced Test Co"
|
||||
|
||||
frappe.init(site=SITE, sites_path="/home/frappe/frappe-bench/sites")
|
||||
frappe.connect()
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
|
||||
def out(key, value):
|
||||
print(f"ENV {key}={value}")
|
||||
|
||||
|
||||
def exists(doctype, name):
|
||||
return bool(frappe.db.exists(doctype, name))
|
||||
|
||||
|
||||
def insert(doc):
|
||||
d = frappe.get_doc(doc)
|
||||
d.insert(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
return d
|
||||
|
||||
|
||||
# ---- 1. site, optionally India Compliance (before the wizard so it can add its own accounts) ----
|
||||
if IC and "india_compliance" not in frappe.get_installed_apps():
|
||||
from frappe.installer import install_app
|
||||
|
||||
install_app("india_compliance", verbose=False)
|
||||
frappe.db.commit()
|
||||
|
||||
# ---- 2. setup wizard ----
|
||||
if not frappe.db.get_single_value("System Settings", "setup_complete"):
|
||||
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
|
||||
|
||||
setup_complete(
|
||||
{
|
||||
"language": "English",
|
||||
"country": "India",
|
||||
"timezone": "Asia/Kolkata",
|
||||
"currency": "INR",
|
||||
"full_name": "Voiced Admin",
|
||||
"email": "admin@voiced.test",
|
||||
"password": "admin",
|
||||
"company_name": COMPANY,
|
||||
"company_abbr": ABBR,
|
||||
"domains": ["Services"],
|
||||
"chart_of_accounts": "Standard",
|
||||
"fy_start_date": "2026-04-01",
|
||||
"fy_end_date": "2027-03-31",
|
||||
"bank_account": "HDFC",
|
||||
"setup_demo": 0,
|
||||
}
|
||||
)
|
||||
# setup_complete may run its stages on a worker; wait for it.
|
||||
for _ in range(120):
|
||||
frappe.db.rollback()
|
||||
if frappe.db.get_single_value("System Settings", "setup_complete") and exists("Company", COMPANY):
|
||||
break
|
||||
time.sleep(2)
|
||||
frappe.clear_cache()
|
||||
frappe.db.commit()
|
||||
assert exists("Company", COMPANY), "setup wizard did not create the company"
|
||||
|
||||
# ---- 3. accounts ----
|
||||
duties = frappe.db.get_value("Account", {"company": COMPANY, "account_name": "Duties and Taxes", "is_group": 1}, "name")
|
||||
assert duties, "Duties and Taxes group not found"
|
||||
for head in ("CGST", "SGST", "UTGST", "IGST"):
|
||||
name = f"Output Tax {head} - {ABBR}"
|
||||
if not exists("Account", name):
|
||||
insert(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": f"Output Tax {head}",
|
||||
"parent_account": duties,
|
||||
"company": COMPANY,
|
||||
"account_type": "Tax",
|
||||
"root_type": "Liability",
|
||||
"is_group": 0,
|
||||
}
|
||||
)
|
||||
tds = f"TDS Receivable - {ABBR}"
|
||||
if not exists("Account", tds):
|
||||
parent = frappe.db.get_value("Account", {"company": COMPANY, "account_name": "Current Assets", "is_group": 1}, "name")
|
||||
insert(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": "TDS Receivable",
|
||||
"parent_account": parent,
|
||||
"company": COMPANY,
|
||||
"account_type": "Tax",
|
||||
"root_type": "Asset",
|
||||
"is_group": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# ---- 4. company address (India Compliance needs one with a GSTIN-ish state) ----
|
||||
addr_name = f"{COMPANY}-Billing"
|
||||
if not exists("Address", addr_name):
|
||||
d = {
|
||||
"doctype": "Address",
|
||||
"address_title": COMPANY,
|
||||
"address_type": "Billing",
|
||||
"address_line1": "1 Test Street",
|
||||
"city": "Mumbai",
|
||||
"state": "Maharashtra",
|
||||
"pincode": "400001",
|
||||
"country": "India",
|
||||
"links": [{"link_doctype": "Company", "link_name": COMPANY}],
|
||||
}
|
||||
if IC:
|
||||
d["gst_category"] = "Registered Regular"
|
||||
d["gstin"] = "27AAPFU0939F1ZV"
|
||||
insert(d)
|
||||
|
||||
# ---- 5. items / UOMs ----
|
||||
for uom in ("Hour", "Minute", "Second"):
|
||||
if not exists("UOM", uom):
|
||||
insert({"doctype": "UOM", "uom_name": uom})
|
||||
for code, uom in (("VOICED-SERVICE", "Nos"), ("VOICED-HOURLY", "Hour")):
|
||||
if not exists("Item", code):
|
||||
d = {
|
||||
"doctype": "Item",
|
||||
"item_code": code,
|
||||
"item_name": code,
|
||||
"item_group": "Services",
|
||||
"stock_uom": uom,
|
||||
"is_stock_item": 0,
|
||||
}
|
||||
if IC:
|
||||
d["gst_hsn_code"] = "998314"
|
||||
insert(d)
|
||||
|
||||
# ---- 5b. rounding method ----
|
||||
if ROUNDING:
|
||||
frappe.db.set_single_value("System Settings", "rounding_method", ROUNDING)
|
||||
frappe.db.commit()
|
||||
frappe.clear_cache()
|
||||
|
||||
# ---- 6. integration user ----
|
||||
USER = f"voiced-integration@{SITE.replace('.', '-')}.test"
|
||||
# The role set that was verified live (with and without India Compliance): "Accounts User" creates and submits Sales
|
||||
# Invoices and Payment Entries and reads the accounting masters; "Sales User" adds the Customer and Address rights
|
||||
# (look up, create) and the tax-template list. Accounts User alone gets HTTP 403 on Customer.
|
||||
ROLES = ["Accounts User", "Sales User"]
|
||||
if not exists("User", USER):
|
||||
insert(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": USER,
|
||||
"first_name": "Voiced",
|
||||
"last_name": "Integration",
|
||||
"send_welcome_email": 0,
|
||||
"user_type": "System User",
|
||||
"roles": [{"role": r} for r in ROLES],
|
||||
}
|
||||
)
|
||||
user = frappe.get_doc("User", USER)
|
||||
user.set("roles", [{"role": r} for r in ROLES])
|
||||
secret = frappe.generate_hash(length=32)
|
||||
if not user.api_key:
|
||||
user.api_key = frappe.generate_hash(length=15)
|
||||
user.api_secret = secret
|
||||
user.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
frappe.clear_cache(user=USER)
|
||||
frappe.clear_cache()
|
||||
|
||||
out("ERPNEXT_KEY", user.api_key)
|
||||
out("ERPNEXT_SECRET", secret)
|
||||
out("ERPNEXT_COMPANY", COMPANY)
|
||||
out("ERPNEXT_ABBR", ABBR)
|
||||
out("ERPNEXT_IC", "1" if IC else "0")
|
||||
out("ERPNEXT_USER", USER)
|
||||
out("ERPNEXT_ROUNDING", "commercial" if frappe.db.get_single_value("System Settings", "rounding_method") == "Commercial Rounding" else "banker")
|
||||
print(json.dumps({"roles": ROLES}))
|
||||
frappe.destroy()
|
||||
@@ -116,7 +116,7 @@ async function main() {
|
||||
const { computeLogoPlacement, slotFor } = await loadPlacement();
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "public/brand/sample-wordmark-logo.png")));
|
||||
const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "src/pdf/testing/fixtures/sample-wordmark-logo.png")));
|
||||
const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height);
|
||||
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
|
||||
const syn = syntheticLogos(createCanvas);
|
||||
|
||||
@@ -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}` },
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +509,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -532,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -545,10 +555,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -875,6 +891,20 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
"multiversion_no_op",
|
||||
"rustversion",
|
||||
"scopeguard",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1244,6 +1274,17 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1415,6 +1456,25 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.14.2",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1531,6 +1591,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1541,6 +1602,21 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.21"
|
||||
@@ -1560,9 +1636,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1891,6 +1969,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.21",
|
||||
"walkdir",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -2090,6 +2198,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2152,6 +2270,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2439,6 +2563,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2866,21 +2996,31 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2916,6 +3056,20 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
@@ -2979,12 +3133,91 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2994,6 +3227,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -3051,6 +3293,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.38.0"
|
||||
@@ -3175,6 +3440,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.24.0"
|
||||
@@ -3272,6 +3549,22 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
@@ -3396,6 +3689,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.8"
|
||||
@@ -3459,6 +3758,27 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3480,7 +3800,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -3490,7 +3810,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -3546,7 +3866,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -3763,7 +4083,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -3786,7 +4106,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -3972,9 +4292,31 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
@@ -3984,6 +4326,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -4188,6 +4531,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -4211,6 +4560,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
@@ -4223,6 +4578,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4286,15 +4647,17 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voiced"
|
||||
version = "0.1.0"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"image",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"rusqlite_migration",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -4306,7 +4669,9 @@ dependencies = [
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4508,6 +4873,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.39.1"
|
||||
@@ -4672,6 +5046,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -4699,6 +5084,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -4988,7 +5382,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -5149,6 +5543,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
@@ -5182,6 +5582,19 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap 2.14.2",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[package]
|
||||
name = "voiced"
|
||||
version = "0.1.0"
|
||||
version = "1.0.2"
|
||||
license = "Apache-2.0"
|
||||
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
||||
authors = ["Bongbetic"]
|
||||
edition = "2021"
|
||||
@@ -28,8 +29,14 @@ thiserror = "2"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
flate2 = "1"
|
||||
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
|
||||
percent-encoding = "2"
|
||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
# ERPNext integration: all HTTP runs in Rust. TLS is rustls with the `ring` provider only (no aws-lc,
|
||||
# which does not cross-compile cleanly under cargo-xwin); roots come from the OS via rustls-platform-verifier.
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "http2", "charset", "system-proxy", "json", "multipart", "query"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
|
||||
# Desktop-only plugins (not available on mobile targets).
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
@@ -38,6 +45,7 @@ tauri-plugin-window-state = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["rt", "macros", "net", "io-util", "time"] }
|
||||
|
||||
[features]
|
||||
# Production builds embed the frontend and serve it over the custom asset
|
||||
|
||||
@@ -30,7 +30,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
||||
|
||||
/// The hash comes from the database, but it ends up in a file path, so check its shape.
|
||||
fn archive_path(local_dir: &Path, sha256: &str) -> Result<PathBuf, String> {
|
||||
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
||||
let valid = sha256.len() == 64
|
||||
&& sha256
|
||||
.bytes()
|
||||
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
||||
if !valid {
|
||||
return Err("The stored archive hash is malformed".to_string());
|
||||
}
|
||||
@@ -72,7 +75,9 @@ pub fn archive_pdf_impl(
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||
if status != "issued" && status != "cancelled" {
|
||||
return Err(format!("Only an issued invoice can be archived (this one is {status})"));
|
||||
return Err(format!(
|
||||
"Only an issued invoice can be archived (this one is {status})"
|
||||
));
|
||||
}
|
||||
|
||||
let sha = sha256_hex(bytes);
|
||||
@@ -89,7 +94,9 @@ pub fn archive_pdf_impl(
|
||||
std::fs::create_dir_all(path.parent().expect("archive path has a parent"))
|
||||
.map_err(|e| format!("Could not create the archive folder: {e}"))?;
|
||||
// Same content, same name; a matching length means the file is already in place.
|
||||
let present = std::fs::metadata(&path).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);
|
||||
let present = std::fs::metadata(&path)
|
||||
.map(|m| m.len() == bytes.len() as u64)
|
||||
.unwrap_or(false);
|
||||
if !present {
|
||||
write_atomic(&path, bytes)?;
|
||||
}
|
||||
@@ -107,7 +114,8 @@ pub fn archive_pdf_impl(
|
||||
}
|
||||
|
||||
pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result<Vec<u8>, String> {
|
||||
let sha = stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?;
|
||||
let sha =
|
||||
stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?;
|
||||
let path = archive_path(local_dir, &sha)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
@@ -124,7 +132,11 @@ pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result
|
||||
|
||||
fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> {
|
||||
let row: Option<Option<String>> = conn
|
||||
.query_row("SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
row.ok_or_else(|| "Invoice not found".to_string())
|
||||
@@ -140,12 +152,20 @@ pub fn archive_status_impl(conn: &Connection, id: i64) -> Result<ArchiveStatus,
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||
Ok(ArchiveStatus { archived: sha256.is_some(), sha256, fingerprint, archived_at })
|
||||
Ok(ArchiveStatus {
|
||||
archived: sha256.is_some(),
|
||||
sha256,
|
||||
fingerprint,
|
||||
archived_at,
|
||||
})
|
||||
}
|
||||
|
||||
// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread.
|
||||
#[tauri::command]
|
||||
pub async fn archive_pdf(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
||||
pub async fn archive_pdf(
|
||||
request: Request<'_>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let bytes = raw_body(&request)?;
|
||||
let headers = header_map(&request);
|
||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
@@ -159,7 +179,10 @@ pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result<ArchiveStatus, String> {
|
||||
pub async fn archive_status(
|
||||
invoice_id: i64,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ArchiveStatus, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
archive_status_impl(&conn, invoice_id)
|
||||
}
|
||||
@@ -195,7 +218,10 @@ mod tests {
|
||||
let mut conn = conn_with_invoice("issued");
|
||||
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||
assert_eq!(sha.len(), 64);
|
||||
assert_eq!(std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), PDF);
|
||||
assert_eq!(
|
||||
std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(),
|
||||
PDF
|
||||
);
|
||||
|
||||
let status = archive_status_impl(&conn, 1).unwrap();
|
||||
assert!(status.archived);
|
||||
@@ -205,7 +231,10 @@ mod tests {
|
||||
|
||||
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||
assert_eq!(again, sha);
|
||||
assert_eq!(archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), first_at);
|
||||
assert_eq!(
|
||||
archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(),
|
||||
first_at
|
||||
);
|
||||
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||
}
|
||||
|
||||
@@ -221,19 +250,27 @@ mod tests {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = conn_with_invoice("issued");
|
||||
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||
let err = archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err();
|
||||
let err =
|
||||
archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err();
|
||||
assert!(err.contains("already has an archived original"), "{err}");
|
||||
assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha);
|
||||
assert_eq!(std::fs::read_dir(dir.path().join("archive")).unwrap().count(), 1);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(dir.path().join("archive"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_pdf_and_oversize_bodies_are_refused() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = conn_with_invoice("issued");
|
||||
assert!(archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
||||
assert!(
|
||||
archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
||||
.unwrap_err()
|
||||
.contains("not a PDF"));
|
||||
.contains("not a PDF")
|
||||
);
|
||||
let mut big = b"%PDF-".to_vec();
|
||||
big.resize(MAX_ARCHIVE_BYTES + 1, 0);
|
||||
assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1"))
|
||||
@@ -264,15 +301,21 @@ mod tests {
|
||||
fn read_archive_detects_corruption_and_missing_files() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = conn_with_invoice("issued");
|
||||
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("no archived PDF"));
|
||||
assert!(read_archive_impl(&conn, dir.path(), 1)
|
||||
.unwrap_err()
|
||||
.contains("no archived PDF"));
|
||||
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||
let file = dir.path().join("archive").join(format!("{sha}.pdf"));
|
||||
|
||||
std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap();
|
||||
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("corrupted"));
|
||||
assert!(read_archive_impl(&conn, dir.path(), 1)
|
||||
.unwrap_err()
|
||||
.contains("corrupted"));
|
||||
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("missing"));
|
||||
assert!(read_archive_impl(&conn, dir.path(), 1)
|
||||
.unwrap_err()
|
||||
.contains("missing"));
|
||||
// Archiving the same bytes again restores the file.
|
||||
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||
@@ -282,8 +325,13 @@ mod tests {
|
||||
fn a_malformed_stored_hash_never_becomes_a_path() {
|
||||
let dir = tempdir().unwrap();
|
||||
let conn = conn_with_invoice("issued");
|
||||
conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", [])
|
||||
conn.execute(
|
||||
"UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed"));
|
||||
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)) {
|
||||
return Err("Asset path must not contain '..'".into());
|
||||
}
|
||||
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
|
||||
let full = if raw.is_absolute() {
|
||||
raw.to_path_buf()
|
||||
} else {
|
||||
data_dir.join(raw)
|
||||
};
|
||||
let root = data_dir
|
||||
.join("assets")
|
||||
.canonicalize()
|
||||
@@ -73,7 +77,12 @@ pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result<String, String
|
||||
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
|
||||
let (ext, _) = sniff_image(bytes)?;
|
||||
let dir = assets_dir(data_dir)?;
|
||||
let file = format!("{}-{}.{}", safe_kind(kind), uuid::Uuid::new_v4().simple(), ext);
|
||||
let file = format!(
|
||||
"{}-{}.{}",
|
||||
safe_kind(kind),
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
ext
|
||||
);
|
||||
std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?;
|
||||
Ok(format!("assets/{file}"))
|
||||
}
|
||||
@@ -105,7 +114,11 @@ fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
|
||||
|
||||
fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> {
|
||||
let raw = Path::new(path);
|
||||
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
|
||||
let full = if raw.is_absolute() {
|
||||
raw.to_path_buf()
|
||||
} else {
|
||||
data_dir.join(raw)
|
||||
};
|
||||
// Removing something that is already gone is fine, but an escape attempt is not.
|
||||
if std::fs::symlink_metadata(&full).is_err() {
|
||||
return Ok(());
|
||||
@@ -163,7 +176,10 @@ mod tests {
|
||||
fn sniffing_accepts_only_png_and_jpeg() {
|
||||
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
|
||||
assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg"));
|
||||
assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image");
|
||||
assert_eq!(
|
||||
sniff_image(b"GIF89a....").unwrap_err(),
|
||||
"Use a PNG or JPEG image"
|
||||
);
|
||||
assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err());
|
||||
assert!(sniff_image(b"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err());
|
||||
assert!(sniff_image(b"").is_err());
|
||||
@@ -173,7 +189,10 @@ mod tests {
|
||||
fn write_ignores_the_name_and_stores_a_relative_path() {
|
||||
let dir = tempdir().unwrap();
|
||||
let stored = write_asset(dir.path(), "Logo", JPEG).unwrap();
|
||||
assert!(stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), "{stored}");
|
||||
assert!(
|
||||
stored.starts_with("assets/logo-") && stored.ends_with(".jpg"),
|
||||
"{stored}"
|
||||
);
|
||||
assert!(dir.path().join(&stored).is_file());
|
||||
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
|
||||
let uri = asset_data_uri(dir.path(), &stored).unwrap();
|
||||
@@ -210,7 +229,10 @@ mod tests {
|
||||
let absolute = dir.path().join(&stored);
|
||||
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
|
||||
assert_eq!(by_relative, by_absolute);
|
||||
assert_eq!(relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), stored);
|
||||
assert_eq!(
|
||||
relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(),
|
||||
stored
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
use crate::gst;
|
||||
use crate::models::Client;
|
||||
use crate::AppState;
|
||||
use rusqlite::{params, Row};
|
||||
use rusqlite::{params, Connection, Row};
|
||||
use tauri::State;
|
||||
|
||||
const CATEGORIES: &[&str] = &[
|
||||
"registered_regular",
|
||||
"unregistered",
|
||||
"composition",
|
||||
"sez",
|
||||
"overseas",
|
||||
];
|
||||
|
||||
const CLIENT_COLS: &str =
|
||||
"c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
|
||||
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
|
||||
c.payment_terms_days,
|
||||
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
|
||||
|
||||
fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
||||
Ok(Client {
|
||||
id: Some(row.get(0)?),
|
||||
@@ -12,17 +27,57 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
||||
state_code: row.get(4)?,
|
||||
po_number: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
address_line1: row.get(7)?,
|
||||
address_line2: row.get(8)?,
|
||||
city: row.get(9)?,
|
||||
pincode: row.get(10)?,
|
||||
gst_category: row.get(11)?,
|
||||
default_notes: row.get(12)?,
|
||||
payment_terms_days: row.get(13)?,
|
||||
invoice_count: row.get(14)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
|
||||
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
|
||||
pub fn compose_address(
|
||||
line1: &str,
|
||||
line2: &str,
|
||||
city: &str,
|
||||
state_code: &str,
|
||||
pincode: &str,
|
||||
) -> String {
|
||||
let state_name = gst::STATES
|
||||
.iter()
|
||||
.find(|(c, _)| *c == state_code)
|
||||
.map(|(_, n)| *n)
|
||||
.unwrap_or("");
|
||||
let place = [city.trim(), state_name]
|
||||
.iter()
|
||||
.filter(|p| !p.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let pin = pincode.trim();
|
||||
let last = match (place.is_empty(), pin.is_empty()) {
|
||||
(false, false) => format!("{place} - {pin}"),
|
||||
(false, true) => place,
|
||||
(true, false) => pin.to_string(),
|
||||
(true, true) => String::new(),
|
||||
};
|
||||
[line1.trim(), line2.trim(), last.as_str()]
|
||||
.iter()
|
||||
.filter(|l| !l.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn list_clients_impl(conn: &Connection) -> Result<Vec<Client>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, address, gstin, state_code, po_number, created_at
|
||||
FROM clients ORDER BY name COLLATE NOCASE ASC",
|
||||
)
|
||||
.prepare(&format!(
|
||||
"SELECT {CLIENT_COLS} FROM clients c ORDER BY c.name COLLATE NOCASE ASC"
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], map_client)
|
||||
@@ -32,29 +87,101 @@ pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
pub fn save_client_impl(conn: &Connection, client: Client) -> Result<Client, String> {
|
||||
let name = client.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("Client name is required".into());
|
||||
}
|
||||
let gstin = match client.gstin.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "NA" => String::new(),
|
||||
g => {
|
||||
gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?;
|
||||
g.to_string()
|
||||
}
|
||||
};
|
||||
let state_code = client.state_code.trim().to_string();
|
||||
if !state_code.is_empty() && !gst::state_exists(&state_code) {
|
||||
return Err(format!("\"{state_code}\" is not a valid GST state code"));
|
||||
}
|
||||
if !gstin.is_empty() && !state_code.is_empty() && gstin[0..2] != state_code {
|
||||
return Err(format!(
|
||||
"GSTIN starts with state code {}, but the client's state is {}",
|
||||
&gstin[0..2],
|
||||
state_code
|
||||
));
|
||||
}
|
||||
if !CATEGORIES.contains(&client.gst_category.as_str()) {
|
||||
return Err(format!("Unknown GST category \"{}\"", client.gst_category));
|
||||
}
|
||||
let pincode = client.pincode.trim().to_string();
|
||||
if !pincode.is_empty() && !(pincode.len() == 6 && pincode.bytes().all(|b| b.is_ascii_digit())) {
|
||||
return Err("Pincode must be 6 digits".into());
|
||||
}
|
||||
if let Some(days) = client.payment_terms_days {
|
||||
if !(0..=365).contains(&days) {
|
||||
return Err("Payment terms must be between 0 and 365 days".into());
|
||||
}
|
||||
}
|
||||
let line1 = client.address_line1.trim().to_string();
|
||||
let line2 = client.address_line2.trim().to_string();
|
||||
let city = client.city.trim().to_string();
|
||||
// The structured parts win when present; otherwise the incoming (legacy or free-text) address is kept.
|
||||
let address = if line1.is_empty() && line2.is_empty() && city.is_empty() && pincode.is_empty() {
|
||||
client.address.clone()
|
||||
} else {
|
||||
compose_address(&line1, &line2, &city, &state_code, &pincode)
|
||||
};
|
||||
|
||||
let id = match client.id {
|
||||
Some(id) => {
|
||||
conn.execute(
|
||||
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6",
|
||||
params![client.name, client.address, client.gstin, client.state_code, client.po_number, id],
|
||||
let n = conn
|
||||
.execute(
|
||||
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5,
|
||||
address_line1=?6, address_line2=?7, city=?8, pincode=?9, gst_category=?10,
|
||||
default_notes=?11, payment_terms_days=?12
|
||||
WHERE id=?13",
|
||||
params![
|
||||
name,
|
||||
address,
|
||||
gstin,
|
||||
state_code,
|
||||
client.po_number,
|
||||
line1,
|
||||
line2,
|
||||
city,
|
||||
pincode,
|
||||
client.gst_category,
|
||||
client.default_notes,
|
||||
client.payment_terms_days,
|
||||
id
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Err("Client not found".into());
|
||||
}
|
||||
id
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at,
|
||||
address_line1, address_line2, city, pincode, gst_category, default_notes,
|
||||
payment_terms_days)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
client.name,
|
||||
client.address,
|
||||
client.gstin,
|
||||
client.state_code,
|
||||
name,
|
||||
address,
|
||||
gstin,
|
||||
state_code,
|
||||
client.po_number,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
chrono::Utc::now().to_rfc3339(),
|
||||
line1,
|
||||
line2,
|
||||
city,
|
||||
pincode,
|
||||
client.gst_category,
|
||||
client.default_notes,
|
||||
client.payment_terms_days
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -62,17 +189,186 @@ pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, Str
|
||||
}
|
||||
};
|
||||
conn.query_row(
|
||||
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1",
|
||||
&format!("SELECT {CLIENT_COLS} FROM clients c WHERE c.id = ?1"),
|
||||
params![id],
|
||||
map_client,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
/// Deleting is allowed even when invoices reference the client: invoices carry their own
|
||||
/// name/address/GSTIN snapshot and `invoices.client_id` has no foreign key, so issued invoices are
|
||||
/// untouched (their id simply no longer resolves). The UI warns with `invoice_count` first.
|
||||
pub fn delete_client_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_clients_impl(&conn)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
save_client_impl(&conn, client)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
delete_client_impl(&conn, id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::open_in_memory;
|
||||
|
||||
fn blank(name: &str) -> Client {
|
||||
serde_json::from_value(serde_json::json!({ "id": null, "name": name })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_address_orders_parts() {
|
||||
assert_eq!(
|
||||
compose_address("12 MG Road", "Floor 2", "Bengaluru", "29", "560001"),
|
||||
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
|
||||
);
|
||||
assert_eq!(compose_address("", "", "", "", ""), "");
|
||||
assert_eq!(compose_address(" A ", "", "", "", "560001"), "A\n560001");
|
||||
assert_eq!(compose_address("", "", "Pune", "", ""), "Pune");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_round_trip_composes_address() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let mut c = blank("Acme");
|
||||
c.address_line1 = "12 MG Road".into();
|
||||
c.address_line2 = "Floor 2".into();
|
||||
c.city = "Bengaluru".into();
|
||||
c.pincode = "560001".into();
|
||||
c.state_code = "29".into();
|
||||
c.gstin = "29abcde1234f1zw".into();
|
||||
c.gst_category = "registered_regular".into();
|
||||
c.default_notes = "Net 15".into();
|
||||
c.payment_terms_days = Some(15);
|
||||
c.po_number = "PO-9".into();
|
||||
let saved = save_client_impl(&conn, c).unwrap();
|
||||
assert!(saved.id.is_some());
|
||||
assert_eq!(
|
||||
saved.address,
|
||||
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
|
||||
);
|
||||
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
|
||||
let listed = list_clients_impl(&conn).unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
let l = &listed[0];
|
||||
assert_eq!(l.address_line1, "12 MG Road");
|
||||
assert_eq!(l.gst_category, "registered_regular");
|
||||
assert_eq!(l.default_notes, "Net 15");
|
||||
assert_eq!(l.payment_terms_days, Some(15));
|
||||
assert_eq!(l.invoice_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_address_is_kept_when_no_structured_parts() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let mut c = blank("Legacy");
|
||||
c.address = "Old style\nfree text".into();
|
||||
let saved = save_client_impl(&conn, c).unwrap();
|
||||
assert_eq!(saved.address, "Old style\nfree text");
|
||||
assert_eq!(saved.gst_category, "unregistered");
|
||||
// Editing a field other than the address leaves it alone.
|
||||
let mut edit = saved.clone();
|
||||
edit.po_number = "PO-1".into();
|
||||
let again = save_client_impl(&conn, edit).unwrap();
|
||||
assert_eq!(again.address, "Old style\nfree text");
|
||||
assert_eq!(again.id, saved.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_changes_the_row_and_unknown_id_fails() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let saved = save_client_impl(&conn, blank("A")).unwrap();
|
||||
let mut edit = saved.clone();
|
||||
edit.name = "B".into();
|
||||
edit.gst_category = "sez".into();
|
||||
let again = save_client_impl(&conn, edit).unwrap();
|
||||
assert_eq!(
|
||||
(again.name.as_str(), again.gst_category.as_str()),
|
||||
("B", "sez")
|
||||
);
|
||||
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||
let mut ghost = blank("Ghost");
|
||||
ghost.id = Some(999);
|
||||
assert!(save_client_impl(&conn, ghost).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_bad_input() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
assert!(save_client_impl(&conn, blank(" "))
|
||||
.unwrap_err()
|
||||
.contains("name"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("GSTIN"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gstin = "29ABCDE1234F1ZW".into();
|
||||
c.state_code = "27".into();
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("state"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gstin = "NA".into();
|
||||
assert_eq!(save_client_impl(&conn, c).unwrap().gstin, "");
|
||||
|
||||
let mut c = blank("X");
|
||||
c.state_code = "99".into();
|
||||
assert!(save_client_impl(&conn, c).is_err());
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gst_category = "weird".into();
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("category"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.pincode = "56001".into();
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("Pincode"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.payment_terms_days = Some(400);
|
||||
assert!(save_client_impl(&conn, c).is_err());
|
||||
// Only the valid "NA" client above was stored; every rejected save left no row behind.
|
||||
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_keeps_referencing_invoices_and_count_is_listed() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let saved = save_client_impl(&conn, blank("Referenced")).unwrap();
|
||||
let id = saved.id.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, client_id, client_name, client_address, created_at, updated_at)
|
||||
VALUES ('T-1', '2026-01-01', ?1, 'Referenced', 'Snap addr', 'now', 'now')",
|
||||
params![id],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(list_clients_impl(&conn).unwrap()[0].invoice_count, 1);
|
||||
|
||||
delete_client_impl(&conn, id).unwrap();
|
||||
assert!(list_clients_impl(&conn).unwrap().is_empty());
|
||||
let (name, addr): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT client_name, client_address FROM invoices WHERE number='T-1'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Tauri commands for the ERPNext settings: read/save the configuration, test a connection and load the
|
||||
//! lists the settings dropdowns need. The API secret never leaves this module: `erpnext_get_config` returns
|
||||
//! `apiSecretSet` only, and every command resolves a blank secret to the stored one on the Rust side.
|
||||
//! The push commands send issued invoices and payments (see `integrations::erpnext::push`); the database lock is
|
||||
//! never held across a network call.
|
||||
|
||||
use crate::integrations::erpnext::client::ErpClient;
|
||||
use crate::integrations::erpnext::config::{
|
||||
self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView,
|
||||
};
|
||||
use crate::integrations::erpnext::discovery::{
|
||||
self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts,
|
||||
};
|
||||
use crate::integrations::erpnext::push::{self, PaymentPushResult, PushResult};
|
||||
use crate::integrations::SyncStatus;
|
||||
use crate::AppState;
|
||||
use rusqlite::Connection;
|
||||
use tauri::State;
|
||||
|
||||
pub fn get_config_impl(conn: &Connection) -> Result<ErpnextConfigView, String> {
|
||||
Ok(config::load(conn)?.view())
|
||||
}
|
||||
|
||||
/// Merges the form with the stored row (blank secret keeps the stored one) without saving.
|
||||
pub fn resolve_config(
|
||||
conn: &Connection,
|
||||
input: ErpnextConfigInput,
|
||||
) -> Result<ErpnextConfig, String> {
|
||||
let stored = config::load(conn)?;
|
||||
input.resolve(&stored)
|
||||
}
|
||||
|
||||
pub fn save_config_impl(
|
||||
conn: &Connection,
|
||||
input: ErpnextConfigInput,
|
||||
) -> Result<ErpnextConfigView, String> {
|
||||
let cfg = resolve_config(conn, input)?;
|
||||
config::save(conn, &cfg)?;
|
||||
Ok(cfg.view())
|
||||
}
|
||||
|
||||
/// What the connection test needs from the local database.
|
||||
pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
|
||||
let registration: String = conn
|
||||
.query_row(
|
||||
"SELECT gst_registration FROM app_settings WHERE id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT number FROM invoices ORDER BY id")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let invalid_numbers = stmt
|
||||
.query_map([], |r| r.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.filter(|n| !ic_number_ok(n))
|
||||
.take(5)
|
||||
.collect();
|
||||
let next_number = conn
|
||||
.query_row(
|
||||
"SELECT prefix, padding, next_number FROM invoice_series WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
|
||||
[],
|
||||
|r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)),
|
||||
)
|
||||
.ok()
|
||||
.map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next));
|
||||
Ok(LocalFacts {
|
||||
vendor_registered: registration != "unregistered",
|
||||
invalid_numbers,
|
||||
next_number,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn erpnext_get_config(state: State<AppState>) -> Result<ErpnextConfigView, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
get_config_impl(&conn)
|
||||
}
|
||||
|
||||
/// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it.
|
||||
#[tauri::command]
|
||||
pub fn erpnext_save_config(
|
||||
state: State<AppState>,
|
||||
config: ErpnextConfigInput,
|
||||
) -> Result<ErpnextConfigView, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
save_config_impl(&conn, config)
|
||||
}
|
||||
|
||||
/// Tests the form values as typed (saved or not). The database lock is released before any network call.
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_test_connection(
|
||||
state: State<'_, AppState>,
|
||||
config: ErpnextConfigInput,
|
||||
) -> Result<ConnectionTest, String> {
|
||||
let (cfg, local) = {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
(resolve_config(&conn, config)?, local_facts(&conn)?)
|
||||
};
|
||||
let client = ErpClient::from_config(&cfg)?;
|
||||
Ok(discovery::test_connection(&client, &cfg, &local).await?)
|
||||
}
|
||||
|
||||
/// Loads the dropdown lists. Company-scoped lists use `config.company`.
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_load_options(
|
||||
state: State<'_, AppState>,
|
||||
config: ErpnextConfigInput,
|
||||
) -> Result<ErpnextOptions, String> {
|
||||
let cfg = {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
resolve_config(&conn, config)?
|
||||
};
|
||||
let client = ErpClient::from_config(&cfg)?;
|
||||
Ok(client.load_options(&cfg.company).await?)
|
||||
}
|
||||
|
||||
/// A client for the saved settings, or a readable reason why there is none.
|
||||
fn saved_client(state: &AppState) -> Result<ErpClient, String> {
|
||||
let cfg = {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
config::load(&conn)?
|
||||
};
|
||||
if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() {
|
||||
return Err(
|
||||
"ERPNext is not set up yet: enter the address, API key and API secret in Settings."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(ErpClient::from_config(&cfg)?)
|
||||
}
|
||||
|
||||
/// Sends one issued invoice. `submit` omitted follows the "submit on push" setting. A failure comes back as a
|
||||
/// result with `ok: false` (and is recorded), not as an `Err`.
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_push_invoice(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
submit: Option<bool>,
|
||||
) -> Result<PushResult, String> {
|
||||
let http = saved_client(&state)?;
|
||||
Ok(push::push_invoice(&state.db, &state.local_data_dir, &http, id, submit).await)
|
||||
}
|
||||
|
||||
/// Sends several invoices one after another; one row's failure never stops the others.
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_push_invoices(
|
||||
state: State<'_, AppState>,
|
||||
ids: Vec<i64>,
|
||||
submit: Option<bool>,
|
||||
) -> Result<Vec<PushResult>, String> {
|
||||
let http = saved_client(&state)?;
|
||||
Ok(push::push_invoices(&state.db, &state.local_data_dir, &http, &ids, submit).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_push_payment(
|
||||
state: State<'_, AppState>,
|
||||
payment_id: i64,
|
||||
) -> Result<PaymentPushResult, String> {
|
||||
let http = saved_client(&state)?;
|
||||
Ok(push::push_payment(&state.db, &http, payment_id).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn erpnext_sync_status(state: State<AppState>, id: i64) -> Result<SyncStatus, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
push::sync_status(&conn, id)
|
||||
}
|
||||
|
||||
/// The sync state of every invoice that has one, for the History list.
|
||||
#[tauri::command]
|
||||
pub fn erpnext_sync_statuses(state: State<AppState>) -> Result<Vec<SyncStatus>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
push::sync_statuses(&conn)
|
||||
}
|
||||
|
||||
/// `<base>/app/sales-invoice/<name>` for an invoice that was sent.
|
||||
#[tauri::command]
|
||||
pub fn erpnext_open_url(state: State<AppState>, id: i64) -> Result<String, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
push::open_url(&conn, id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
|
||||
let mut base =
|
||||
json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" });
|
||||
base.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(extra.as_object().unwrap().clone());
|
||||
serde_json::from_value(base).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_config_is_returned_without_the_secret() {
|
||||
let conn = crate::db::open_in_memory().unwrap();
|
||||
let view = save_config_impl(
|
||||
&conn,
|
||||
input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true })),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(view.api_secret_set);
|
||||
let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap();
|
||||
assert!(!shown.contains("super-secret-value"));
|
||||
assert!(shown.contains("\"apiSecretSet\":true"));
|
||||
assert!(shown.contains("\"company\":\"Test Co\""));
|
||||
|
||||
// Saving again with a blank secret keeps it for the connection test too.
|
||||
save_config_impl(&conn, input(json!({ "company": "Other Co" }))).unwrap();
|
||||
let resolved = resolve_config(&conn, input(json!({}))).unwrap();
|
||||
assert_eq!(resolved.api_secret.expose(), "super-secret-value");
|
||||
assert_eq!(resolved.company, "Test Co");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_values_are_resolved_without_saving() {
|
||||
let conn = crate::db::open_in_memory().unwrap();
|
||||
let resolved =
|
||||
resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap();
|
||||
assert_eq!(resolved.api_secret.expose(), "typed-not-saved");
|
||||
assert!(!get_config_impl(&conn).unwrap().api_secret_set);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_facts_report_registration_numbers_and_the_next_number() {
|
||||
let conn = crate::db::open_in_memory().unwrap();
|
||||
let facts = local_facts(&conn).unwrap();
|
||||
assert!(!facts.vendor_registered);
|
||||
assert!(facts.invalid_numbers.is_empty());
|
||||
assert!(facts.next_number.is_some());
|
||||
|
||||
conn.execute("UPDATE app_settings SET gst_registration = 'regular'", [])
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, created_at, updated_at)
|
||||
VALUES ('INV/2026-001', '2026-04-01', 'n', 'n'), ('INVOICE/2026/000001', '2026-04-01', 'n', 'n')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let facts = local_facts(&conn).unwrap();
|
||||
assert!(facts.vendor_registered);
|
||||
assert_eq!(facts.invalid_numbers, ["INVOICE/2026/000001"]);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! User-visible files: exports written to a path from the save dialog, and the
|
||||
//! reveal/open helpers used afterwards.
|
||||
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers};
|
||||
use super::raw::{
|
||||
decode_header_path, header_map, raw_body, required_header, write_atomic, Headers,
|
||||
};
|
||||
use crate::AppState;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -25,7 +27,9 @@ pub fn write_export_file_impl(
|
||||
if bytes.is_empty() {
|
||||
return Err("Nothing to write: the file is empty".to_string());
|
||||
}
|
||||
let parent = path.parent().ok_or_else(|| "The export path has no folder".to_string())?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| "The export path has no folder".to_string())?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||
write_atomic(&path, bytes)?;
|
||||
@@ -38,7 +42,11 @@ pub fn write_export_file_impl(
|
||||
}
|
||||
|
||||
pub fn get_last_export_dir_impl(conn: &Connection) -> Result<String, String> {
|
||||
conn.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||
conn.query_row(
|
||||
"SELECT last_export_dir FROM app_settings WHERE id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -71,7 +79,11 @@ fn reveal_fallback_command(path: &Path) -> Command {
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
|
||||
let folder = if path.is_dir() {
|
||||
path
|
||||
} else {
|
||||
path.parent().unwrap_or(path)
|
||||
};
|
||||
let mut cmd = Command::new("xdg-open");
|
||||
cmd.arg(folder);
|
||||
cmd
|
||||
@@ -90,7 +102,9 @@ fn open_fallback_command(path: &Path) -> Command {
|
||||
}
|
||||
|
||||
fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
||||
let mut child = cmd.spawn().map_err(|e| format!("Could not start the file manager: {e}"))?;
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not start the file manager: {e}"))?;
|
||||
// Reap the child so it does not linger as a zombie; the result is irrelevant
|
||||
// (explorer.exe, for one, exits non-zero even on success).
|
||||
std::thread::spawn(move || {
|
||||
@@ -100,7 +114,10 @@ fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_export_file(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
||||
pub async fn write_export_file(
|
||||
request: Request<'_>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let bytes = raw_body(&request)?;
|
||||
let headers = header_map(&request);
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
@@ -125,7 +142,11 @@ pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), Strin
|
||||
#[tauri::command]
|
||||
pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||
let path = require_existing(&path)?;
|
||||
if app.opener().open_path(path.to_string_lossy(), None::<&str>).is_ok() {
|
||||
if app
|
||||
.opener()
|
||||
.open_path(path.to_string_lossy(), None::<&str>)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
spawn_detached(open_fallback_command(&path))
|
||||
@@ -141,7 +162,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn encode(path: &Path) -> String {
|
||||
percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC)
|
||||
percent_encoding::utf8_percent_encode(
|
||||
&path.to_string_lossy(),
|
||||
percent_encoding::NON_ALPHANUMERIC,
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -152,8 +176,12 @@ mod tests {
|
||||
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||
|
||||
// Non-ASCII, spaces and a folder that does not exist yet.
|
||||
let target = dir.path().join("New folder").join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf");
|
||||
let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap();
|
||||
let target = dir
|
||||
.path()
|
||||
.join("New folder")
|
||||
.join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf");
|
||||
let written =
|
||||
write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap();
|
||||
assert_eq!(PathBuf::from(&written), target);
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7");
|
||||
assert_eq!(
|
||||
@@ -170,7 +198,10 @@ mod tests {
|
||||
fn rejects_empty_relative_and_missing_paths() {
|
||||
let conn = crate::db::open_in_memory().unwrap();
|
||||
for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] {
|
||||
assert!(write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), "{bad}");
|
||||
assert!(
|
||||
write_export_file_impl(&conn, b"x", &headers(bad)).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err());
|
||||
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||
@@ -191,7 +222,9 @@ mod tests {
|
||||
let dir = tempdir().unwrap();
|
||||
assert!(require_existing("").is_err());
|
||||
let missing = dir.path().join("nope.pdf");
|
||||
assert!(require_existing(&missing.to_string_lossy()).unwrap_err().contains("does not exist"));
|
||||
assert!(require_existing(&missing.to_string_lossy())
|
||||
.unwrap_err()
|
||||
.contains("does not exist"));
|
||||
assert!(require_existing(&dir.path().to_string_lossy()).is_ok());
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
//! (`fvar`, `CFF2`: only the default instance would embed), files without outlines, and fonts whose OS/2 `fsType`
|
||||
//! forbids embedding. The sfnt is parsed by hand (no font crate is available offline); only the tables needed
|
||||
//! to describe the font are read.
|
||||
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers};
|
||||
use super::raw::{
|
||||
decode_header_path, header_map, raw_body, required_header, write_atomic, Headers,
|
||||
};
|
||||
use crate::AppState;
|
||||
use flate2::read::ZlibDecoder;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
@@ -119,7 +121,10 @@ impl<'a> Sfnt<'a> {
|
||||
let off = be32(data, rec + 8).ok_or("The font file is truncated")? as usize;
|
||||
let len = be32(data, rec + 12).ok_or("The font file is truncated")? as usize;
|
||||
if off.checked_add(len).map_or(true, |end| end > data.len()) {
|
||||
return Err(format!("Table '{}' lies outside the file", String::from_utf8_lossy(&tag)));
|
||||
return Err(format!(
|
||||
"Table '{}' lies outside the file",
|
||||
String::from_utf8_lossy(&tag)
|
||||
));
|
||||
}
|
||||
tables.push((tag, off, len));
|
||||
}
|
||||
@@ -135,7 +140,10 @@ impl<'a> Sfnt<'a> {
|
||||
}
|
||||
|
||||
fn utf16be(b: &[u8]) -> String {
|
||||
let units: Vec<u16> = b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let units: Vec<u16> = b
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
}
|
||||
|
||||
@@ -201,7 +209,11 @@ impl<'a> Cmap<'a> {
|
||||
best = Some((rank, offset, format));
|
||||
}
|
||||
}
|
||||
best.map(|(_, offset, format)| Self { table, offset, format })
|
||||
best.map(|(_, offset, format)| Self {
|
||||
table,
|
||||
offset,
|
||||
format,
|
||||
})
|
||||
}
|
||||
|
||||
fn has(&self, cp: u32) -> bool {
|
||||
@@ -283,7 +295,9 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let comp = be32(data, rec + 8).ok_or_else(bad)? as usize;
|
||||
let orig = be32(data, rec + 12).ok_or_else(bad)? as usize;
|
||||
let checksum = be32(data, rec + 16).ok_or_else(bad)?;
|
||||
let stored = data.get(off..off.checked_add(comp).ok_or_else(bad)?).ok_or_else(bad)?;
|
||||
let stored = data
|
||||
.get(off..off.checked_add(comp).ok_or_else(bad)?)
|
||||
.ok_or_else(bad)?;
|
||||
total += (orig + 3) & !3;
|
||||
if orig > MAX_FONT_BYTES || total > MAX_FONT_BYTES {
|
||||
return Err("The font expands to more than the 16 MB limit".into());
|
||||
@@ -293,7 +307,10 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
} else if comp < orig {
|
||||
let mut out = Vec::with_capacity(orig);
|
||||
// One byte over the stated size detects a table that lies about it, without trusting it for allocation.
|
||||
ZlibDecoder::new(stored).take(orig as u64 + 1).read_to_end(&mut out).map_err(|_| bad())?;
|
||||
ZlibDecoder::new(stored)
|
||||
.take(orig as u64 + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|_| bad())?;
|
||||
if out.len() != orig {
|
||||
return Err(bad());
|
||||
}
|
||||
@@ -301,7 +318,11 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
} else {
|
||||
return Err(bad());
|
||||
};
|
||||
entries.push(Entry { tag, checksum, bytes });
|
||||
entries.push(Entry {
|
||||
tag,
|
||||
checksum,
|
||||
bytes,
|
||||
});
|
||||
}
|
||||
entries.sort_by_key(|e| e.tag);
|
||||
|
||||
@@ -372,12 +393,18 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
|
||||
}
|
||||
let cff = font.table(b"CFF ").is_some();
|
||||
if !cff && (font.table(b"glyf").is_none() || font.table(b"loca").is_none()) {
|
||||
return Err("The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded.".into());
|
||||
return Err(
|
||||
"The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let head = font.table(b"head").ok_or("The font has no head table")?;
|
||||
let cmap_table = font.table(b"cmap").ok_or("The font has no cmap table")?;
|
||||
let name = font.table(b"name").ok_or("The font has no name table")?;
|
||||
let num_glyphs = font.table(b"maxp").and_then(|m| be16(m, 4)).ok_or("The font has no usable maxp table")?;
|
||||
let num_glyphs = font
|
||||
.table(b"maxp")
|
||||
.and_then(|m| be16(m, 4))
|
||||
.ok_or("The font has no usable maxp table")?;
|
||||
|
||||
let os2 = font.table(b"OS/2");
|
||||
let fs_type = os2.and_then(|t| be16(t, 8)).unwrap_or(0);
|
||||
@@ -413,7 +440,11 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
|
||||
let fs_selection = os2.and_then(|t| be16(t, 62)).unwrap_or(0);
|
||||
let mac_style = be16(head, 44).unwrap_or(0);
|
||||
// fsSelection bit 0 = italic, bit 9 = oblique; head.macStyle bit 1 = italic.
|
||||
let italic = if os2.map_or(false, |t| t.len() >= 64) { fs_selection & 0x0201 != 0 } else { mac_style & 0x2 != 0 };
|
||||
let italic = if os2.map_or(false, |t| t.len() >= 64) {
|
||||
fs_selection & 0x0201 != 0
|
||||
} else {
|
||||
mac_style & 0x2 != 0
|
||||
};
|
||||
|
||||
let cmap = Cmap::new(cmap_table).ok_or("The font has no usable Unicode character map")?;
|
||||
let has_rupee = cmap.has(0x20B9);
|
||||
@@ -428,9 +459,14 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
|
||||
warnings.push("The font has no rupee sign (\u{20B9}); amounts fall back to IBM Plex Sans for that glyph.".to_string());
|
||||
}
|
||||
|
||||
let family = name_string(name, 16).or_else(|| name_string(name, 1)).unwrap_or_default();
|
||||
let subfamily = name_string(name, 17).or_else(|| name_string(name, 2)).unwrap_or_default();
|
||||
let full_name = name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string());
|
||||
let family = name_string(name, 16)
|
||||
.or_else(|| name_string(name, 1))
|
||||
.unwrap_or_default();
|
||||
let subfamily = name_string(name, 17)
|
||||
.or_else(|| name_string(name, 2))
|
||||
.unwrap_or_default();
|
||||
let full_name =
|
||||
name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string());
|
||||
let postscript_name = name_string(name, 6).unwrap_or_default();
|
||||
if family.is_empty() && full_name.is_empty() {
|
||||
return Err("The font has no readable name".into());
|
||||
@@ -460,7 +496,10 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
|
||||
// storage
|
||||
|
||||
fn font_path(local_dir: &Path, sha256: &str, format: &str) -> Result<PathBuf, String> {
|
||||
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
||||
let valid = sha256.len() == 64
|
||||
&& sha256
|
||||
.bytes()
|
||||
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
||||
if !valid {
|
||||
return Err("The stored font hash is malformed".to_string());
|
||||
}
|
||||
@@ -493,19 +532,31 @@ fn map_row(r: &rusqlite::Row) -> rusqlite::Result<UserFont> {
|
||||
}
|
||||
|
||||
fn get_font(conn: &Connection, id: i64) -> Result<Option<UserFont>, String> {
|
||||
conn.query_row(&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"), params![id], map_row)
|
||||
conn.query_row(
|
||||
&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"),
|
||||
params![id],
|
||||
map_row,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn list_user_fonts_impl(conn: &Connection, include_hidden: bool) -> Result<Vec<UserFont>, String> {
|
||||
pub fn list_user_fonts_impl(
|
||||
conn: &Connection,
|
||||
include_hidden: bool,
|
||||
) -> Result<Vec<UserFont>, String> {
|
||||
let sql = format!(
|
||||
"SELECT {COLS} FROM user_fonts {} ORDER BY face, weight, style, id",
|
||||
if include_hidden { "" } else { "WHERE hidden = 0" }
|
||||
if include_hidden {
|
||||
""
|
||||
} else {
|
||||
"WHERE hidden = 0"
|
||||
}
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map([], map_row).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Whether any invoice's frozen render prefs name this font file.
|
||||
@@ -531,7 +582,11 @@ pub struct ImportRequest<'a> {
|
||||
fn clean_file_name(raw: &str) -> String {
|
||||
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
|
||||
let cleaned: String = base.chars().filter(|c| !c.is_control()).take(120).collect();
|
||||
if cleaned.trim().is_empty() { "font".to_string() } else { cleaned }
|
||||
if cleaned.trim().is_empty() {
|
||||
"font".to_string()
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_font_impl(
|
||||
@@ -541,7 +596,10 @@ pub fn import_font_impl(
|
||||
req: &ImportRequest,
|
||||
) -> Result<UserFont, String> {
|
||||
if !FACES.contains(&req.face) {
|
||||
return Err(format!("Fonts can be imported for {} only", FACES.join(", ")));
|
||||
return Err(format!(
|
||||
"Fonts can be imported for {} only",
|
||||
FACES.join(", ")
|
||||
));
|
||||
}
|
||||
if !req.licence_acknowledged {
|
||||
return Err("Confirm that you hold a licence for this font before importing it".into());
|
||||
@@ -567,7 +625,9 @@ pub fn import_font_impl(
|
||||
let path = font_path(local_dir, &info.sha256, &info.format)?;
|
||||
std::fs::create_dir_all(path.parent().expect("font path has a parent"))
|
||||
.map_err(|e| format!("Could not create the fonts folder: {e}"))?;
|
||||
let present = std::fs::metadata(&path).map(|m| m.len() == sfnt.len() as u64).unwrap_or(false);
|
||||
let present = std::fs::metadata(&path)
|
||||
.map(|m| m.len() == sfnt.len() as u64)
|
||||
.unwrap_or(false);
|
||||
if !present {
|
||||
write_atomic(&path, &sfnt)?;
|
||||
}
|
||||
@@ -628,12 +688,21 @@ pub fn import_font_impl(
|
||||
fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result<Option<PathBuf>, String> {
|
||||
let font = get_font(conn, id)?.ok_or_else(|| "Font not found".to_string())?;
|
||||
if referenced_by_invoice(conn, &font.sha256)? {
|
||||
conn.execute("UPDATE user_fonts SET hidden = 1 WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"UPDATE user_fonts SET hidden = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(None);
|
||||
}
|
||||
conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
let shared: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1", params![font.sha256], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1",
|
||||
params![font.sha256],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if shared > 0 {
|
||||
return Ok(None);
|
||||
@@ -641,11 +710,19 @@ fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result<Option<PathBuf
|
||||
font_path(local_dir, &font.sha256, &font.format).map(Some)
|
||||
}
|
||||
|
||||
pub fn remove_user_font_impl(conn: &mut Connection, local_dir: &Path, id: i64) -> Result<RemoveOutcome, String> {
|
||||
pub fn remove_user_font_impl(
|
||||
conn: &mut Connection,
|
||||
local_dir: &Path,
|
||||
id: i64,
|
||||
) -> Result<RemoveOutcome, String> {
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
let file = retire(&tx, local_dir, id)?;
|
||||
let still_there: bool = tx
|
||||
.query_row("SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)", params![id], |r| r.get::<_, i64>(0))
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)",
|
||||
params![id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?
|
||||
!= 0;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
@@ -653,13 +730,24 @@ pub fn remove_user_font_impl(conn: &mut Connection, local_dir: &Path, id: i64) -
|
||||
// Best effort, as in import: the row is already gone.
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
Ok(RemoveOutcome { deleted: !still_there, hidden: still_there })
|
||||
Ok(RemoveOutcome {
|
||||
deleted: !still_there,
|
||||
hidden: still_there,
|
||||
})
|
||||
}
|
||||
|
||||
/// The stored sfnt for a font file, hidden or not (an issued invoice must still be able to render with it).
|
||||
pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) -> Result<Vec<u8>, String> {
|
||||
pub fn user_font_bytes_impl(
|
||||
conn: &Connection,
|
||||
local_dir: &Path,
|
||||
sha256: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let format: Option<String> = conn
|
||||
.query_row("SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1", params![sha256], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1",
|
||||
params![sha256],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let format = format.ok_or_else(|| "This font is not in the font library".to_string())?;
|
||||
@@ -677,20 +765,43 @@ pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) -
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn parse_import_headers<'a>(headers: &'a Headers, file_name: &'a str) -> Result<ImportRequest<'a>, String> {
|
||||
fn parse_import_headers<'a>(
|
||||
headers: &'a Headers,
|
||||
file_name: &'a str,
|
||||
) -> Result<ImportRequest<'a>, String> {
|
||||
let face = required_header(headers, "x-face")?;
|
||||
let weight = match headers.get("x-weight").map(|v| v.trim()).filter(|v| !v.is_empty()) {
|
||||
let weight = match headers
|
||||
.get("x-weight")
|
||||
.map(|v| v.trim())
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
None => None,
|
||||
Some(v) => Some(v.parse::<u16>().map_err(|_| "x-weight must be a number".to_string())?),
|
||||
Some(v) => Some(
|
||||
v.parse::<u16>()
|
||||
.map_err(|_| "x-weight must be a number".to_string())?,
|
||||
),
|
||||
};
|
||||
let italic = match headers.get("x-style").map(|v| v.trim()).filter(|v| !v.is_empty()) {
|
||||
let italic = match headers
|
||||
.get("x-style")
|
||||
.map(|v| v.trim())
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
None => None,
|
||||
Some("normal") => Some(false),
|
||||
Some("italic") => Some(true),
|
||||
Some(_) => return Err("x-style must be normal or italic".into()),
|
||||
};
|
||||
let licence_acknowledged = headers.get("x-licence-ack").map(|v| v.trim() == "true").unwrap_or(false);
|
||||
Ok(ImportRequest { face, file_name, weight, italic, licence_acknowledged })
|
||||
let licence_acknowledged = headers
|
||||
.get("x-licence-ack")
|
||||
.map(|v| v.trim() == "true")
|
||||
.unwrap_or(false);
|
||||
Ok(ImportRequest {
|
||||
face,
|
||||
file_name,
|
||||
weight,
|
||||
italic,
|
||||
licence_acknowledged,
|
||||
})
|
||||
}
|
||||
|
||||
// The commands are async so parsing, hashing and file I/O of a multi-MB font stay off the main thread.
|
||||
@@ -701,10 +812,18 @@ pub async fn inspect_font(request: Request<'_>) -> Result<FontInspection, String
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Result<UserFont, String> {
|
||||
pub async fn import_font(
|
||||
request: Request<'_>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<UserFont, String> {
|
||||
let bytes = raw_body(&request)?;
|
||||
let headers = header_map(&request);
|
||||
let file_name = decode_header_path(headers.get("x-file-name").map(String::as_str).unwrap_or("font"))?;
|
||||
let file_name = decode_header_path(
|
||||
headers
|
||||
.get("x-file-name")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("font"),
|
||||
)?;
|
||||
let face = decode_header_path(required_header(&headers, "x-face")?)?;
|
||||
let mut headers = headers;
|
||||
headers.insert("x-face".to_string(), face);
|
||||
@@ -714,19 +833,28 @@ pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Re
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_user_fonts(include_hidden: Option<bool>, state: State<'_, AppState>) -> Result<Vec<UserFont>, String> {
|
||||
pub async fn list_user_fonts(
|
||||
include_hidden: Option<bool>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<UserFont>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_user_fonts_impl(&conn, include_hidden.unwrap_or(false))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_font_bytes(sha256: String, state: State<'_, AppState>) -> Result<Response, String> {
|
||||
pub async fn get_user_font_bytes(
|
||||
sha256: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Response, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
user_font_bytes_impl(&conn, &state.local_data_dir, &sha256).map(Response::new)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_user_font(id: i64, state: State<'_, AppState>) -> Result<RemoveOutcome, String> {
|
||||
pub async fn remove_user_font(
|
||||
id: i64,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<RemoveOutcome, String> {
|
||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
remove_user_font_impl(&mut conn, &state.local_data_dir, id)
|
||||
}
|
||||
@@ -740,7 +868,9 @@ mod tests {
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn jost(name: &str) -> Vec<u8> {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/jost").join(name);
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../public/fonts/jost")
|
||||
.join(name);
|
||||
std::fs::read(path).unwrap()
|
||||
}
|
||||
fn regular() -> Vec<u8> {
|
||||
@@ -781,7 +911,11 @@ mod tests {
|
||||
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||
enc.write_all(raw).unwrap();
|
||||
let z = enc.finish().unwrap();
|
||||
blobs.push(if z.len() < raw.len() { (*tag, z, raw.len()) } else { (*tag, raw.to_vec(), raw.len()) });
|
||||
blobs.push(if z.len() < raw.len() {
|
||||
(*tag, z, raw.len())
|
||||
} else {
|
||||
(*tag, raw.to_vec(), raw.len())
|
||||
});
|
||||
}
|
||||
let mut out = vec![0u8; 44 + 20 * n];
|
||||
out[0..4].copy_from_slice(b"wOFF");
|
||||
@@ -803,7 +937,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn request<'a>(face: &'a str, ack: bool) -> ImportRequest<'a> {
|
||||
ImportRequest { face, file_name: "C:\\fonts\\Jost-Regular.ttf", weight: None, italic: None, licence_acknowledged: ack }
|
||||
ImportRequest {
|
||||
face,
|
||||
file_name: "C:\\fonts\\Jost-Regular.ttf",
|
||||
weight: None,
|
||||
italic: None,
|
||||
licence_acknowledged: ack,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -821,9 +961,17 @@ mod tests {
|
||||
assert_eq!(info.sha256, sha256_hex(®ular()));
|
||||
assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings);
|
||||
assert!(info.warnings[0].contains("rupee"));
|
||||
let poppins = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/poppins/Poppins-Regular.ttf")).unwrap();
|
||||
let poppins = std::fs::read(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../public/fonts/poppins/Poppins-Regular.ttf"),
|
||||
)
|
||||
.unwrap();
|
||||
let (_, with_rupee) = inspect_bytes(&poppins).unwrap();
|
||||
assert!(with_rupee.has_rupee && with_rupee.warnings.is_empty(), "{:?}", with_rupee.warnings);
|
||||
assert!(
|
||||
with_rupee.has_rupee && with_rupee.warnings.is_empty(),
|
||||
"{:?}",
|
||||
with_rupee.warnings
|
||||
);
|
||||
assert_eq!(inspect_bytes(&bold()).unwrap().1.weight, 700);
|
||||
}
|
||||
|
||||
@@ -835,7 +983,9 @@ mod tests {
|
||||
let mut ttc = regular();
|
||||
ttc[0..4].copy_from_slice(b"ttcf");
|
||||
assert!(inspect_bytes(&ttc).unwrap_err().contains("collections"));
|
||||
assert!(inspect_bytes(b"<!doctype html><html></html>").unwrap_err().contains("not a TrueType"));
|
||||
assert!(inspect_bytes(b"<!doctype html><html></html>")
|
||||
.unwrap_err()
|
||||
.contains("not a TrueType"));
|
||||
assert!(inspect_bytes(b"short").is_err());
|
||||
let mut big = regular();
|
||||
big.resize(MAX_FONT_BYTES + 1, 0);
|
||||
@@ -856,7 +1006,9 @@ mod tests {
|
||||
fn fs_type_rules() {
|
||||
let mut restricted = regular();
|
||||
set_fs_type(&mut restricted, FS_RESTRICTED);
|
||||
assert!(inspect_bytes(&restricted).unwrap_err().contains("restricted"));
|
||||
assert!(inspect_bytes(&restricted)
|
||||
.unwrap_err()
|
||||
.contains("restricted"));
|
||||
|
||||
// A more permissive bit alongside wins (least restrictive).
|
||||
let mut both = regular();
|
||||
@@ -865,11 +1017,21 @@ mod tests {
|
||||
|
||||
let mut preview = regular();
|
||||
set_fs_type(&mut preview, FS_PREVIEW_PRINT);
|
||||
assert!(inspect_bytes(&preview).unwrap().1.warnings.iter().any(|w| w.contains("preview and print")));
|
||||
assert!(inspect_bytes(&preview)
|
||||
.unwrap()
|
||||
.1
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("preview and print")));
|
||||
|
||||
let mut no_subset = regular();
|
||||
set_fs_type(&mut no_subset, FS_NO_SUBSETTING);
|
||||
assert!(inspect_bytes(&no_subset).unwrap().1.warnings.iter().any(|w| w.contains("subset")));
|
||||
assert!(inspect_bytes(&no_subset)
|
||||
.unwrap()
|
||||
.1
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("subset")));
|
||||
|
||||
let mut bitmap = regular();
|
||||
set_fs_type(&mut bitmap, FS_BITMAP_ONLY);
|
||||
@@ -880,13 +1042,18 @@ mod tests {
|
||||
fn refuses_a_font_without_outlines() {
|
||||
let mut font = regular();
|
||||
rename_table(&mut font, b"glyf", b"XXXX");
|
||||
assert!(inspect_bytes(&font).unwrap_err().contains("no glyph outlines"));
|
||||
assert!(inspect_bytes(&font)
|
||||
.unwrap_err()
|
||||
.contains("no glyph outlines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpacks_woff_to_the_same_font() {
|
||||
let woff = to_woff(®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();
|
||||
assert_eq!(info.source_format, "woff");
|
||||
assert_eq!(info.family, "Jost");
|
||||
@@ -897,7 +1064,12 @@ mod tests {
|
||||
let unpacked = Sfnt::parse(&sfnt).unwrap();
|
||||
assert_eq!(original.tables.len(), unpacked.tables.len());
|
||||
for (tag, _, _) in &original.tables {
|
||||
assert_eq!(original.table(tag), unpacked.table(tag), "table {}", String::from_utf8_lossy(tag));
|
||||
assert_eq!(
|
||||
original.table(tag),
|
||||
unpacked.table(tag),
|
||||
"table {}",
|
||||
String::from_utf8_lossy(tag)
|
||||
);
|
||||
}
|
||||
let mut damaged = woff.clone();
|
||||
let len = damaged.len();
|
||||
@@ -909,17 +1081,28 @@ mod tests {
|
||||
fn import_stores_content_addressed_and_round_trips() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let row = import_font_impl(&mut conn, dir.path(), ®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.weight, row.style.as_str(), row.format.as_str()), (400, "normal", "ttf"));
|
||||
assert_eq!(row.file_name, "Jost-Regular.ttf", "only the base name is kept");
|
||||
assert_eq!(
|
||||
(row.weight, row.style.as_str(), row.format.as_str()),
|
||||
(400, "normal", "ttf")
|
||||
);
|
||||
assert_eq!(
|
||||
row.file_name, "Jost-Regular.ttf",
|
||||
"only the base name is kept"
|
||||
);
|
||||
assert!(!row.hidden && row.licence_ack_at.len() > 10);
|
||||
let path = dir.path().join("fonts").join(format!("{}.ttf", row.sha256));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), regular());
|
||||
assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular());
|
||||
assert_eq!(
|
||||
user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(),
|
||||
regular()
|
||||
);
|
||||
|
||||
// Importing the same file again is a no-op.
|
||||
let again = import_font_impl(&mut conn, dir.path(), ®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!(list_user_fonts_impl(&conn, false).unwrap().len(), 1);
|
||||
|
||||
@@ -933,18 +1116,32 @@ mod tests {
|
||||
fn import_overrides_and_guards() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", false))
|
||||
assert!(
|
||||
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", false))
|
||||
.unwrap_err()
|
||||
.contains("licence"));
|
||||
assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Montserrat", true))
|
||||
.contains("licence")
|
||||
);
|
||||
assert!(import_font_impl(
|
||||
&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");
|
||||
assert!(
|
||||
!dir.path().join("fonts").exists(),
|
||||
"a refused import writes nothing"
|
||||
);
|
||||
let mut woff2 = regular();
|
||||
woff2[0..4].copy_from_slice(b"wOF2");
|
||||
assert!(import_font_impl(&mut conn, dir.path(), &woff2, &request("Now", true)).is_err());
|
||||
|
||||
let req = ImportRequest { weight: Some(300), italic: Some(true), ..request("Open Sauce One", true) };
|
||||
let req = ImportRequest {
|
||||
weight: Some(300),
|
||||
italic: Some(true),
|
||||
..request("Open Sauce One", true)
|
||||
};
|
||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &req).unwrap();
|
||||
assert_eq!((row.weight, row.style.as_str()), (300, "italic"));
|
||||
// The table itself refuses other faces.
|
||||
@@ -962,15 +1159,23 @@ mod tests {
|
||||
fn replacing_a_slot_removes_the_old_unreferenced_font() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let first = import_font_impl(&mut conn, dir.path(), ®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).
|
||||
let req = ImportRequest { weight: Some(400), ..request("Gotham", true) };
|
||||
let req = ImportRequest {
|
||||
weight: Some(400),
|
||||
..request("Gotham", true)
|
||||
};
|
||||
let second = import_font_impl(&mut conn, dir.path(), &bold(), &req).unwrap();
|
||||
assert_ne!(first.sha256, second.sha256);
|
||||
let rows = list_user_fonts_impl(&conn, true).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].sha256, second.sha256);
|
||||
assert!(!dir.path().join("fonts").join(format!("{}.ttf", first.sha256)).exists());
|
||||
assert!(!dir
|
||||
.path()
|
||||
.join("fonts")
|
||||
.join(format!("{}.ttf", first.sha256))
|
||||
.exists());
|
||||
}
|
||||
|
||||
fn issue_invoice_with_prefs(conn: &Connection, prefs: &str) {
|
||||
@@ -986,11 +1191,22 @@ mod tests {
|
||||
fn remove_deletes_when_unreferenced() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let row = import_font_impl(&mut conn, dir.path(), ®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();
|
||||
assert_eq!(out, RemoveOutcome { deleted: true, hidden: false });
|
||||
assert_eq!(
|
||||
out,
|
||||
RemoveOutcome {
|
||||
deleted: true,
|
||||
hidden: false
|
||||
}
|
||||
);
|
||||
assert!(list_user_fonts_impl(&conn, true).unwrap().is_empty());
|
||||
assert!(!dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists());
|
||||
assert!(!dir
|
||||
.path()
|
||||
.join("fonts")
|
||||
.join(format!("{}.ttf", row.sha256))
|
||||
.exists());
|
||||
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).is_err());
|
||||
assert!(remove_user_font_impl(&mut conn, dir.path(), row.id).is_err());
|
||||
}
|
||||
@@ -999,20 +1215,42 @@ mod tests {
|
||||
fn remove_hides_a_font_an_issued_invoice_references() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||
let row =
|
||||
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||
issue_invoice_with_prefs(
|
||||
&conn,
|
||||
&format!(r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#, row.sha256),
|
||||
&format!(
|
||||
r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#,
|
||||
row.sha256
|
||||
),
|
||||
);
|
||||
let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap();
|
||||
assert_eq!(out, RemoveOutcome { deleted: false, hidden: true });
|
||||
assert_eq!(
|
||||
out,
|
||||
RemoveOutcome {
|
||||
deleted: false,
|
||||
hidden: true
|
||||
}
|
||||
);
|
||||
// Gone from the visible list, but kept and still served so the invoice can re-render.
|
||||
assert!(list_user_fonts_impl(&conn, false).unwrap().is_empty());
|
||||
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 1);
|
||||
assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular());
|
||||
assert_eq!(
|
||||
user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(),
|
||||
regular()
|
||||
);
|
||||
|
||||
// The slot is free again: importing a new font for it works, and the hidden one stays.
|
||||
let fresh = import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap();
|
||||
let fresh = import_font_impl(
|
||||
&mut conn,
|
||||
dir.path(),
|
||||
&bold(),
|
||||
&ImportRequest {
|
||||
weight: Some(400),
|
||||
..request("Gotham", true)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(fresh.sha256, row.sha256);
|
||||
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 2);
|
||||
}
|
||||
@@ -1021,10 +1259,27 @@ mod tests {
|
||||
fn replacing_a_referenced_font_hides_it_instead_of_deleting() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||
issue_invoice_with_prefs(&conn, &format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256));
|
||||
import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap();
|
||||
assert!(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists());
|
||||
let row =
|
||||
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||
issue_invoice_with_prefs(
|
||||
&conn,
|
||||
&format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256),
|
||||
);
|
||||
import_font_impl(
|
||||
&mut conn,
|
||||
dir.path(),
|
||||
&bold(),
|
||||
&ImportRequest {
|
||||
weight: Some(400),
|
||||
..request("Gotham", true)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(dir
|
||||
.path()
|
||||
.join("fonts")
|
||||
.join(format!("{}.ttf", row.sha256))
|
||||
.exists());
|
||||
let all = list_user_fonts_impl(&conn, true).unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all.iter().filter(|f| f.hidden).count(), 1);
|
||||
@@ -1034,22 +1289,38 @@ mod tests {
|
||||
fn a_file_shared_by_two_faces_survives_removing_one() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let a = import_font_impl(&mut conn, dir.path(), ®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();
|
||||
assert_eq!(a.sha256, b.sha256);
|
||||
remove_user_font_impl(&mut conn, dir.path(), a.id).unwrap();
|
||||
assert!(dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists());
|
||||
assert!(dir
|
||||
.path()
|
||||
.join("fonts")
|
||||
.join(format!("{}.ttf", a.sha256))
|
||||
.exists());
|
||||
remove_user_font_impl(&mut conn, dir.path(), b.id).unwrap();
|
||||
assert!(!dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists());
|
||||
assert!(!dir
|
||||
.path()
|
||||
.join("fonts")
|
||||
.join(format!("{}.ttf", a.sha256))
|
||||
.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_are_verified_and_the_hash_shape_checked() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
||||
std::fs::write(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)), b"tampered").unwrap();
|
||||
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap_err().contains("corrupted"));
|
||||
let row =
|
||||
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("fonts").join(format!("{}.ttf", row.sha256)),
|
||||
b"tampered",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256)
|
||||
.unwrap_err()
|
||||
.contains("corrupted"));
|
||||
assert!(font_path(dir.path(), "../../etc/passwd", "ttf").is_err());
|
||||
}
|
||||
|
||||
@@ -1061,11 +1332,18 @@ mod tests {
|
||||
h.insert("x-weight".into(), "700".into());
|
||||
h.insert("x-style".into(), "italic".into());
|
||||
let req = parse_import_headers(&h, "f.ttf").unwrap();
|
||||
assert_eq!((req.weight, req.italic, req.licence_acknowledged), (Some(700), Some(true), true));
|
||||
assert_eq!(
|
||||
(req.weight, req.italic, req.licence_acknowledged),
|
||||
(Some(700), Some(true), true)
|
||||
);
|
||||
h.insert("x-style".into(), "oblique".into());
|
||||
assert!(parse_import_headers(&h, "f.ttf").is_err());
|
||||
h.remove("x-licence-ack");
|
||||
h.insert("x-style".into(), "".into());
|
||||
assert!(!parse_import_headers(&h, "f.ttf").unwrap().licence_acknowledged);
|
||||
assert!(
|
||||
!parse_import_headers(&h, "f.ttf")
|
||||
.unwrap()
|
||||
.licence_acknowledged
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use super::assets::relative_asset_path;
|
||||
use super::payments::{derive_summary, summary_for, today_ist};
|
||||
use super::series::{validate_series_format, MAX_NUMBER_LEN};
|
||||
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
||||
use crate::db::format_number;
|
||||
use crate::gst::{self, TaxType};
|
||||
use crate::models::{
|
||||
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
|
||||
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
|
||||
Settings,
|
||||
};
|
||||
use crate::AppState;
|
||||
use chrono::NaiveDate;
|
||||
@@ -59,6 +61,8 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||
archived_fingerprint: row.get(35)?,
|
||||
archived_at: row.get(36)?,
|
||||
items: Vec::new(),
|
||||
payment: PaymentSummary::default(),
|
||||
total_paise: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,6 +94,8 @@ fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
|
||||
map_invoice,
|
||||
)?;
|
||||
invoice.items = fetch_items(conn, id)?;
|
||||
invoice.total_paise = gst::rupees_to_paise(invoice.total);
|
||||
invoice.payment = summary_for(conn, id, &invoice.status, invoice.total, &invoice.due_date)?;
|
||||
Ok(invoice)
|
||||
}
|
||||
|
||||
@@ -135,7 +141,9 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
|
||||
match item.mode.as_str() {
|
||||
"rate" => {
|
||||
if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 {
|
||||
return Err(format!("Line {n}: rate and quantity must be positive numbers"));
|
||||
return Err(format!(
|
||||
"Line {n}: rate and quantity must be positive numbers"
|
||||
));
|
||||
}
|
||||
if item.quantity > MAX_LINE_RUPEES {
|
||||
return Err(format!("Line {n}: quantity is too large"));
|
||||
@@ -266,8 +274,9 @@ pub fn issue_invoice_impl(
|
||||
.map_err(db)?
|
||||
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
|
||||
|
||||
validate_series_format(&prefix, padding)
|
||||
.map_err(|e| format!("The active invoice series cannot be used: {e}. Start a new series."))?;
|
||||
validate_series_format(&prefix, padding).map_err(|e| {
|
||||
format!("The active invoice series cannot be used: {e}. Start a new series.")
|
||||
})?;
|
||||
let number = format_number(&prefix, padding, next_number);
|
||||
if number.len() > MAX_NUMBER_LEN {
|
||||
return Err(format!(
|
||||
@@ -291,10 +300,16 @@ pub fn issue_invoice_impl(
|
||||
};
|
||||
let pos = input.place_of_supply_state_code.trim().to_string();
|
||||
if !pos.is_empty() && !gst::state_exists(&pos) {
|
||||
return Err(format!("Place of supply \"{pos}\" is not a valid GST state code"));
|
||||
return Err(format!(
|
||||
"Place of supply \"{pos}\" is not a valid GST state code"
|
||||
));
|
||||
}
|
||||
|
||||
let derived = gst::derive_tax_type(&settings.gst_registration, &settings.vendor_state_code, &pos);
|
||||
let derived = gst::derive_tax_type(
|
||||
&settings.gst_registration,
|
||||
&settings.vendor_state_code,
|
||||
&pos,
|
||||
);
|
||||
if input.tax_type != derived.as_str() {
|
||||
return Err(format!(
|
||||
"Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}",
|
||||
@@ -304,7 +319,11 @@ pub fn issue_invoice_impl(
|
||||
if pos.is_empty() { &settings.vendor_state_code } else { &pos },
|
||||
));
|
||||
}
|
||||
let tax_rate = if derived == TaxType::None { 0.0 } else { input.tax_rate };
|
||||
let tax_rate = if derived == TaxType::None {
|
||||
0.0
|
||||
} else {
|
||||
input.tax_rate
|
||||
};
|
||||
if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) {
|
||||
return Err("Tax rate must be between 0 and 100".into());
|
||||
}
|
||||
@@ -325,9 +344,9 @@ pub fn issue_invoice_impl(
|
||||
let snapshot = vendor_snapshot(&settings);
|
||||
let bank = bank_snapshot(&tx, input.bank_account_id)?;
|
||||
let signature_path = match input.signature_path.as_deref().map(str::trim) {
|
||||
Some(p) if !p.is_empty() => Some(
|
||||
relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?,
|
||||
),
|
||||
Some(p) if !p.is_empty() => {
|
||||
Some(relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?;
|
||||
@@ -338,15 +357,16 @@ pub fn issue_invoice_impl(
|
||||
Some(id) => Some(id),
|
||||
None if input.save_client && !input.client_name.trim().is_empty() => {
|
||||
tx.execute(
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at, gst_category)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
input.client_name,
|
||||
input.client_address,
|
||||
client_gstin,
|
||||
stored_pos,
|
||||
input.po_number,
|
||||
now
|
||||
now,
|
||||
if client_gstin.is_empty() { "unregistered" } else { "registered_regular" }
|
||||
],
|
||||
)
|
||||
.map_err(db)?;
|
||||
@@ -439,7 +459,10 @@ pub fn issue_invoice_impl(
|
||||
.map_err(db)?;
|
||||
|
||||
if let Some(draft_id) = input.draft_id {
|
||||
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id])
|
||||
tx.execute(
|
||||
"DELETE FROM invoice_drafts WHERE id = ?1",
|
||||
params![draft_id],
|
||||
)
|
||||
.map_err(db)?;
|
||||
}
|
||||
|
||||
@@ -458,7 +481,21 @@ pub fn issue_invoice(
|
||||
}
|
||||
|
||||
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
||||
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
|
||||
pub fn cancel_invoice_impl(
|
||||
conn: &mut Connection,
|
||||
id: i64,
|
||||
reason: &str,
|
||||
) -> Result<Invoice, String> {
|
||||
let payments: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM payments WHERE invoice_id = ?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if payments > 0 {
|
||||
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
|
||||
}
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let changed = conn
|
||||
.execute(
|
||||
@@ -470,7 +507,11 @@ pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Resu
|
||||
.map_err(|e| e.to_string())?;
|
||||
if changed == 0 {
|
||||
let status: Option<String> = conn
|
||||
.query_row("SELECT status FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT status FROM invoices WHERE id = ?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Err(match status {
|
||||
@@ -490,23 +531,49 @@ pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result
|
||||
#[tauri::command]
|
||||
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_invoices_impl(&conn)
|
||||
}
|
||||
|
||||
/// One query: invoices left-joined to their payment sums. Status derivation happens in Rust
|
||||
/// (`derive_summary`) so list and detail agree.
|
||||
pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, String> {
|
||||
let today = today_ist();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, number, invoice_date, due_date, client_name, total, status, created_at
|
||||
FROM invoices ORDER BY id DESC",
|
||||
"SELECT i.id, i.number, i.invoice_date, i.due_date, i.client_name, i.total, i.status,
|
||||
i.created_at, i.po_number, COALESCE(p.paid, 0), COALESCE(p.tds, 0)
|
||||
FROM invoices i
|
||||
LEFT JOIN (SELECT invoice_id, SUM(amount_paise) AS paid, SUM(tds_paise) AS tds
|
||||
FROM payments GROUP BY invoice_id) p ON p.invoice_id = i.id
|
||||
ORDER BY i.id DESC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let total: f64 = row.get(5)?;
|
||||
let status: String = row.get(6)?;
|
||||
let due_date: String = row.get(3)?;
|
||||
let total_paise = gst::rupees_to_paise(total);
|
||||
let payment = derive_summary(
|
||||
&status,
|
||||
total_paise,
|
||||
row.get(9)?,
|
||||
row.get(10)?,
|
||||
&due_date,
|
||||
&today,
|
||||
);
|
||||
Ok(InvoiceSummary {
|
||||
id: row.get(0)?,
|
||||
number: row.get(1)?,
|
||||
invoice_date: row.get(2)?,
|
||||
due_date: row.get(3)?,
|
||||
due_date,
|
||||
client_name: row.get(4)?,
|
||||
total: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
total,
|
||||
status,
|
||||
created_at: row.get(7)?,
|
||||
po_number: row.get(8)?,
|
||||
total_paise,
|
||||
payment,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -515,10 +582,92 @@ pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, Stri
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
|
||||
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InvoiceLedgerRow {
|
||||
pub id: i64,
|
||||
pub client_gstin: String,
|
||||
pub place_of_supply_code: String,
|
||||
pub doc_type: String,
|
||||
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
|
||||
pub hsn_sac: Vec<String>,
|
||||
/// Subtotal less discount.
|
||||
pub taxable_paise: i64,
|
||||
pub cgst_paise: i64,
|
||||
pub sgst_paise: i64,
|
||||
pub igst_paise: i64,
|
||||
pub reverse_charge: bool,
|
||||
}
|
||||
|
||||
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> =
|
||||
std::collections::HashMap::new();
|
||||
{
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let items = stmt
|
||||
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
for item in items {
|
||||
let (id, code) = item.map_err(|e| e.to_string())?;
|
||||
let list = hsn_by_invoice.entry(id).or_default();
|
||||
if !code.is_empty() && !list.contains(&code) {
|
||||
list.push(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
|
||||
cgst_amount, sgst_amount, igst_amount, reverse_charge
|
||||
FROM invoices ORDER BY id DESC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let id: i64 = r.get(0)?;
|
||||
Ok(InvoiceLedgerRow {
|
||||
id,
|
||||
client_gstin: r.get(1)?,
|
||||
place_of_supply_code: r.get(2)?,
|
||||
doc_type: r.get(3)?,
|
||||
hsn_sac: Vec::new(),
|
||||
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
|
||||
cgst_paise: gst::rupees_to_paise(r.get(6)?),
|
||||
sgst_paise: gst::rupees_to_paise(r.get(7)?),
|
||||
igst_paise: gst::rupees_to_paise(r.get(8)?),
|
||||
reverse_charge: r.get::<_, i64>(9)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|mut row| {
|
||||
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
|
||||
row
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_invoice_ledger_impl(&conn)
|
||||
}
|
||||
|
||||
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
|
||||
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
fetch_invoice(&conn, id).map_err(|e| e.to_string())
|
||||
get_invoice_impl(&conn, id)
|
||||
}
|
||||
|
||||
pub fn save_draft_impl(
|
||||
@@ -554,11 +703,17 @@ pub fn save_draft_impl(
|
||||
|
||||
pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC")
|
||||
.prepare(
|
||||
"SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?))
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
@@ -570,7 +725,10 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
|
||||
DraftSummary {
|
||||
id,
|
||||
updated_at,
|
||||
client_name: json.get("clientName").and_then(|v| v.as_str()).map(String::from),
|
||||
client_name: json
|
||||
.get("clientName")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
total: json.get("total").and_then(|v| v.as_f64()),
|
||||
}
|
||||
})
|
||||
@@ -579,7 +737,11 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
|
||||
|
||||
pub fn get_draft_impl(conn: &Connection, id: i64) -> Result<serde_json::Value, String> {
|
||||
let text: String = conn
|
||||
.query_row("SELECT payload FROM invoice_drafts WHERE id = ?1", params![id], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT payload FROM invoice_drafts WHERE id = ?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "Draft not found".to_string())?;
|
||||
@@ -653,12 +815,46 @@ mod tests {
|
||||
}
|
||||
|
||||
fn next_number(conn: &Connection) -> i64 {
|
||||
conn.query_row("SELECT next_number FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||
conn.query_row(
|
||||
"SELECT next_number FROM invoice_series WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn invoice_count(conn: &Connection) -> i64 {
|
||||
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
||||
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
|
||||
let mut conn = registered();
|
||||
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1",
|
||||
params![first.id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
|
||||
params![first.id],
|
||||
)
|
||||
.unwrap();
|
||||
let ledger = list_invoice_ledger_impl(&conn).unwrap();
|
||||
assert_eq!(ledger.len(), 1);
|
||||
let row = &ledger[0];
|
||||
assert_eq!(row.id, first.id);
|
||||
assert_eq!(row.place_of_supply_code, "27");
|
||||
assert_eq!(row.doc_type, "tax_invoice");
|
||||
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
|
||||
assert_eq!(row.taxable_paise, 731_000);
|
||||
assert_eq!(
|
||||
(row.cgst_paise, row.sgst_paise, row.igst_paise),
|
||||
(65_790, 65_790, 0)
|
||||
);
|
||||
assert!(!row.reverse_charge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -728,7 +924,10 @@ mod tests {
|
||||
input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""), "{err}");
|
||||
assert!(
|
||||
err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""),
|
||||
"{err}"
|
||||
);
|
||||
let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err();
|
||||
assert!(err.contains("does not match"), "{err}");
|
||||
assert_eq!(next_number(&conn), 1);
|
||||
@@ -738,7 +937,11 @@ mod tests {
|
||||
#[test]
|
||||
fn unregistered_supplier_issues_a_plain_invoice_without_tax() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'", []).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let inv = issue(
|
||||
&mut conn,
|
||||
input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})),
|
||||
@@ -795,7 +998,8 @@ mod tests {
|
||||
assert!(inv.amount_in_words.contains("Eight Thousand"));
|
||||
|
||||
// Later settings edits do not touch the stored snapshot.
|
||||
conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", []).unwrap();
|
||||
conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", [])
|
||||
.unwrap();
|
||||
let again = fetch_invoice(&conn, inv.id).unwrap();
|
||||
assert_eq!(again.vendor_snapshot, inv.vendor_snapshot);
|
||||
}
|
||||
@@ -803,10 +1007,12 @@ mod tests {
|
||||
#[test]
|
||||
fn bank_snapshot_is_built_from_the_bank_row() {
|
||||
let mut conn = registered();
|
||||
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap();
|
||||
let bank_id: i64 = conn
|
||||
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
|
||||
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
|
||||
assert_eq!(bank["bankName"], "State Bank of India");
|
||||
assert_eq!(bank["bankName"], "Test Bank");
|
||||
let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err();
|
||||
assert!(err.contains("bank account"), "{err}");
|
||||
}
|
||||
@@ -822,15 +1028,22 @@ mod tests {
|
||||
params![format!("INV/{year}-001")],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", [])
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err();
|
||||
assert!(err.contains("already exists"), "{err}");
|
||||
assert_eq!(next_number(&conn), 1);
|
||||
assert_eq!(invoice_count(&conn), 1);
|
||||
// The client insert and the draft delete were rolled back too.
|
||||
let clients: i64 = conn.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0)).unwrap();
|
||||
let drafts: i64 = conn.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0)).unwrap();
|
||||
let clients: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let drafts: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!((clients, drafts), (0, 1));
|
||||
}
|
||||
|
||||
@@ -845,10 +1058,19 @@ mod tests {
|
||||
json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}),
|
||||
"greater than zero",
|
||||
),
|
||||
(json!({"items": [{"mode": "fixed", "amount": -1.0}]}), "zero or more"),
|
||||
(json!({"items": [{"mode": "weird", "amount": 1.0}]}), "unknown line type"),
|
||||
(
|
||||
json!({"items": [{"mode": "fixed", "amount": -1.0}]}),
|
||||
"zero or more",
|
||||
),
|
||||
(
|
||||
json!({"items": [{"mode": "weird", "amount": 1.0}]}),
|
||||
"unknown line type",
|
||||
),
|
||||
(json!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"),
|
||||
(json!({"placeOfSupplyStateCode": "99"}), "not a valid GST state code"),
|
||||
(
|
||||
json!({"placeOfSupplyStateCode": "99"}),
|
||||
"not a valid GST state code",
|
||||
),
|
||||
(json!({"taxRate": 150.0}), "between 0 and 100"),
|
||||
(json!({"signaturePath": "../voiced.db"}), "Signature image"),
|
||||
];
|
||||
@@ -867,31 +1089,58 @@ mod tests {
|
||||
#[test]
|
||||
fn supplier_settings_are_validated() {
|
||||
let mut conn = registered();
|
||||
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'", []).unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("check digit"));
|
||||
conn.execute("UPDATE app_settings SET vendor_gstin = ''", []).unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("needs a GSTIN"));
|
||||
conn.execute("UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'", []).unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code 29"));
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(issue(&mut conn, input(json!({})))
|
||||
.unwrap_err()
|
||||
.contains("check digit"));
|
||||
conn.execute("UPDATE app_settings SET vendor_gstin = ''", [])
|
||||
.unwrap();
|
||||
assert!(issue(&mut conn, input(json!({})))
|
||||
.unwrap_err()
|
||||
.contains("needs a GSTIN"));
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(issue(&mut conn, input(json!({})))
|
||||
.unwrap_err()
|
||||
.contains("state code 29"));
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("PAN"));
|
||||
conn.execute("UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'", [])
|
||||
assert!(issue(&mut conn, input(json!({})))
|
||||
.unwrap_err()
|
||||
.contains("PAN"));
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code"));
|
||||
assert!(issue(&mut conn, input(json!({})))
|
||||
.unwrap_err()
|
||||
.contains("state code"));
|
||||
assert_eq!(next_number(&conn), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_series_that_break_the_rules_are_refused() {
|
||||
let mut conn = registered();
|
||||
conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", []).unwrap();
|
||||
conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", [])
|
||||
.unwrap();
|
||||
let err = issue(&mut conn, input(json!({}))).unwrap_err();
|
||||
assert!(err.contains("active invoice series"), "{err}");
|
||||
conn.execute("UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3", []).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).is_err());
|
||||
// A counter that outgrows its padding can push the number past 16 characters.
|
||||
conn.execute(
|
||||
@@ -899,7 +1148,9 @@ mod tests {
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("longer than 16"));
|
||||
assert!(issue(&mut conn, input(json!({})))
|
||||
.unwrap_err()
|
||||
.contains("longer than 16"));
|
||||
assert_eq!(invoice_count(&conn), 0);
|
||||
}
|
||||
|
||||
@@ -927,7 +1178,10 @@ mod tests {
|
||||
&json!({"theme": "plain"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(inv.signature_path.as_deref(), Some("assets/signature-1.png"));
|
||||
assert_eq!(
|
||||
inv.signature_path.as_deref(),
|
||||
Some("assets/signature-1.png")
|
||||
);
|
||||
let prefs: String = conn
|
||||
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
@@ -948,8 +1202,14 @@ mod tests {
|
||||
|
||||
let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err();
|
||||
assert!(err.contains("cancelled"), "{err}");
|
||||
assert!(cancel_invoice_impl(&mut conn, 999, "").unwrap_err().contains("not found"));
|
||||
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![inv.id]).unwrap();
|
||||
assert!(cancel_invoice_impl(&mut conn, 999, "")
|
||||
.unwrap_err()
|
||||
.contains("not found"));
|
||||
conn.execute(
|
||||
"UPDATE invoices SET status = 'draft' WHERE id = ?1",
|
||||
params![inv.id],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err());
|
||||
|
||||
// The number is never reused.
|
||||
@@ -961,10 +1221,16 @@ mod tests {
|
||||
#[test]
|
||||
fn drafts_round_trip() {
|
||||
let conn = crate::db::open_in_memory().unwrap();
|
||||
let a = save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap();
|
||||
let a =
|
||||
save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap();
|
||||
let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap();
|
||||
assert_ne!(a, b);
|
||||
let c = save_draft_impl(&conn, Some(a), &json!({"clientName": "Acme 2", "total": 10})).unwrap();
|
||||
let c = save_draft_impl(
|
||||
&conn,
|
||||
Some(a),
|
||||
&json!({"clientName": "Acme 2", "total": 10}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(c, a);
|
||||
assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err());
|
||||
|
||||
@@ -977,7 +1243,8 @@ mod tests {
|
||||
let blank = list.iter().find(|d| d.id == b).unwrap();
|
||||
assert_eq!((blank.client_name.clone(), blank.total), (None, None));
|
||||
|
||||
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a]).unwrap();
|
||||
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a])
|
||||
.unwrap();
|
||||
assert!(get_draft_impl(&conn, a).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,22 @@ pub struct LogoAsset {
|
||||
}
|
||||
|
||||
fn sha8(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().take(4).map(|b| format!("{b:02x}")).collect()
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive the print and knockout images from `bytes` and store them content-addressed in the asset
|
||||
/// store. `existing_original` is the already stored file the bytes came from (legacy logos); without
|
||||
/// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at
|
||||
/// older logo files.
|
||||
fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option<String>) -> Result<LogoAsset, String> {
|
||||
fn store_logo(
|
||||
data_dir: &Path,
|
||||
bytes: &[u8],
|
||||
existing_original: Option<String>,
|
||||
) -> Result<LogoAsset, String> {
|
||||
let derived = process_logo(bytes).map_err(|e| e.to_string())?;
|
||||
let id = sha8(bytes);
|
||||
let original_path = match existing_original {
|
||||
@@ -38,17 +46,28 @@ fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option<String>)
|
||||
write_named_asset(data_dir, &format!("logo-{id}-original.{ext}"), bytes)?
|
||||
}
|
||||
};
|
||||
let print_path = write_named_asset(data_dir, &format!("logo-{id}-print.png"), &derived.print_png)?;
|
||||
let print_path = write_named_asset(
|
||||
data_dir,
|
||||
&format!("logo-{id}-print.png"),
|
||||
&derived.print_png,
|
||||
)?;
|
||||
let knockout_path = derived
|
||||
.knockout_png
|
||||
.as_deref()
|
||||
.map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png))
|
||||
.transpose()?;
|
||||
Ok(LogoAsset { original_path, print_path, knockout_path, metrics: derived.metrics })
|
||||
Ok(LogoAsset {
|
||||
original_path,
|
||||
print_path,
|
||||
knockout_path,
|
||||
metrics: derived.metrics,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn import_logo_from_path(data_dir: &Path, source_path: &str) -> Result<LogoAsset, String> {
|
||||
let len = std::fs::metadata(source_path).map_err(|e| e.to_string())?.len();
|
||||
let len = std::fs::metadata(source_path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.len();
|
||||
if len > MAX_INPUT_BYTES as u64 {
|
||||
return Err(crate::logo::LogoError::TooLarge.to_string());
|
||||
}
|
||||
@@ -61,7 +80,9 @@ pub fn import_logo_from_base64(data_dir: &Path, data_base64: &str) -> Result<Log
|
||||
if data_base64.len() > MAX_INPUT_BYTES / 3 * 4 + 8 {
|
||||
return Err(crate::logo::LogoError::TooLarge.to_string());
|
||||
}
|
||||
let bytes = STANDARD.decode(data_base64.as_bytes()).map_err(|e| e.to_string())?;
|
||||
let bytes = STANDARD
|
||||
.decode(data_base64.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
store_logo(data_dir, &bytes, None)
|
||||
}
|
||||
|
||||
@@ -74,9 +95,11 @@ fn metrics_json(metrics: &LogoMetrics) -> Result<String, String> {
|
||||
/// original. Returns whether anything changed.
|
||||
pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bool, String> {
|
||||
let (logo_path, meta): (Option<String>, String) = conn
|
||||
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.query_row(
|
||||
"SELECT logo_path, logo_meta FROM app_settings WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else {
|
||||
return Ok(false);
|
||||
@@ -86,11 +109,20 @@ pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bo
|
||||
}
|
||||
let file = resolve_asset(data_dir, &logo_path)?;
|
||||
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
|
||||
let asset = store_logo(data_dir, &bytes, Some(relative_asset_path(data_dir, &logo_path)?))?;
|
||||
let asset = store_logo(
|
||||
data_dir,
|
||||
&bytes,
|
||||
Some(relative_asset_path(data_dir, &logo_path)?),
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
|
||||
logo_meta = ?4 WHERE id = 1",
|
||||
params![asset.print_path, asset.original_path, asset.knockout_path, metrics_json(&asset.metrics)?],
|
||||
params![
|
||||
asset.print_path,
|
||||
asset.original_path,
|
||||
asset.knockout_path,
|
||||
metrics_json(&asset.metrics)?
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
@@ -161,7 +193,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
DynamicImage::ImageRgba8(img).write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
|
||||
DynamicImage::ImageRgba8(img)
|
||||
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -180,16 +214,30 @@ mod tests {
|
||||
let bytes = padded_png();
|
||||
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap();
|
||||
let id = sha8(&bytes);
|
||||
assert_eq!(asset.original_path, format!("assets/logo-{id}-original.png"));
|
||||
assert_eq!(
|
||||
asset.original_path,
|
||||
format!("assets/logo-{id}-original.png")
|
||||
);
|
||||
assert_eq!(asset.print_path, format!("assets/logo-{id}-print.png"));
|
||||
assert_eq!(asset.knockout_path.as_deref(), Some(format!("assets/logo-{id}-knockout.png").as_str()));
|
||||
assert_eq!(
|
||||
asset.knockout_path.as_deref(),
|
||||
Some(format!("assets/logo-{id}-knockout.png").as_str())
|
||||
);
|
||||
assert_eq!((asset.metrics.width, asset.metrics.height), (100, 20));
|
||||
assert_eq!(std::fs::read(dir.path().join(&asset.original_path)).unwrap(), bytes);
|
||||
assert_eq!(
|
||||
std::fs::read(dir.path().join(&asset.original_path)).unwrap(),
|
||||
bytes
|
||||
);
|
||||
for rel in [&asset.print_path, asset.knockout_path.as_ref().unwrap()] {
|
||||
assert!(resolve_asset(dir.path(), rel).is_ok());
|
||||
}
|
||||
// The same bytes land on the same names.
|
||||
assert_eq!(import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap().print_path, asset.print_path);
|
||||
assert_eq!(
|
||||
import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes))
|
||||
.unwrap()
|
||||
.print_path,
|
||||
asset.print_path
|
||||
);
|
||||
let json = serde_json::to_value(&asset).unwrap();
|
||||
assert_eq!(json["metrics"]["kind"], "wordmark");
|
||||
assert_eq!(json["metrics"]["opaqueBackground"], false);
|
||||
@@ -220,7 +268,8 @@ mod tests {
|
||||
let dir = tempdir().unwrap();
|
||||
let conn = crate::db::open_in_memory().unwrap();
|
||||
let legacy = write_named_asset(dir.path(), "logo-legacy.png", &padded_png()).unwrap();
|
||||
conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy]).unwrap();
|
||||
conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy])
|
||||
.unwrap();
|
||||
|
||||
assert!(ensure_logo_derived_impl(&conn, dir.path()).unwrap());
|
||||
let (logo, original, knockout, meta) = logo_columns(&conn);
|
||||
@@ -243,7 +292,8 @@ mod tests {
|
||||
let dir = tempdir().unwrap();
|
||||
let conn = crate::db::open_in_memory().unwrap();
|
||||
assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap());
|
||||
conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", []).unwrap();
|
||||
conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", [])
|
||||
.unwrap();
|
||||
assert!(ensure_logo_derived_impl(&conn, dir.path()).is_err());
|
||||
// Left untouched so a later start can retry.
|
||||
assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png"));
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
pub mod archive;
|
||||
pub mod assets;
|
||||
pub mod backup;
|
||||
pub mod clients;
|
||||
pub mod erpnext;
|
||||
pub mod files;
|
||||
pub mod fonts;
|
||||
pub mod invoice;
|
||||
pub mod logo;
|
||||
pub mod payments;
|
||||
pub mod presets;
|
||||
pub mod raw;
|
||||
pub mod series;
|
||||
pub mod settings;
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
//! Payments received against issued invoices.
|
||||
//!
|
||||
//! Money is integer paise throughout. Payment status is never stored: it is derived from the
|
||||
//! payment rows, the invoice total and the due date, so deleting a payment reverts it for free.
|
||||
//! The issued invoice row is not touched by any of this.
|
||||
|
||||
use crate::gst::rupees_to_paise;
|
||||
use crate::models::PaymentSummary;
|
||||
use crate::AppState;
|
||||
use chrono::{FixedOffset, NaiveDate, Utc};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
const MODES: &[&str] = &["bank_transfer", "upi", "cash", "cheque", "other"];
|
||||
/// Upper bound on one payment (paise); keeps sums far from i64 overflow.
|
||||
const MAX_PAISE: i64 = 100_000_000_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Payment {
|
||||
pub id: i64,
|
||||
pub invoice_id: i64,
|
||||
pub paid_on: String,
|
||||
/// Cash received.
|
||||
pub amount_paise: i64,
|
||||
/// Tax deducted at source by the client; settles the invoice like cash does.
|
||||
pub tds_paise: i64,
|
||||
pub mode: String,
|
||||
pub reference: String,
|
||||
pub notes: String,
|
||||
pub created_at: String,
|
||||
/// Name of the Payment Entry on ERPNext once this payment was sent; `None` until then.
|
||||
#[serde(default)]
|
||||
pub erpnext_payment_entry: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaymentInput {
|
||||
pub invoice_id: i64,
|
||||
pub paid_on: String,
|
||||
pub amount_paise: i64,
|
||||
#[serde(default)]
|
||||
pub tds_paise: i64,
|
||||
pub mode: String,
|
||||
#[serde(default)]
|
||||
pub reference: String,
|
||||
#[serde(default)]
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
|
||||
pub fn today_ist() -> String {
|
||||
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
|
||||
Utc::now()
|
||||
.with_timezone(&ist)
|
||||
.date_naive()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
|
||||
/// with a zero balance (a cancelled invoice is owed nothing). `settled` is cash plus TDS.
|
||||
/// Overdue means a due date strictly before `today` while a balance remains; it wins over
|
||||
/// `partially_paid`, and the paid amounts still show the partial payment.
|
||||
pub fn derive_summary(
|
||||
status: &str,
|
||||
total_paise: i64,
|
||||
paid_paise: i64,
|
||||
tds_paise: i64,
|
||||
due_date: &str,
|
||||
today: &str,
|
||||
) -> PaymentSummary {
|
||||
if status != "issued" {
|
||||
return PaymentSummary {
|
||||
paid_paise,
|
||||
tds_paise,
|
||||
balance_paise: 0,
|
||||
payment_status: "none".to_string(),
|
||||
};
|
||||
}
|
||||
let settled = paid_paise + tds_paise;
|
||||
let balance = (total_paise - settled).max(0);
|
||||
let overdue = balance > 0
|
||||
&& NaiveDate::parse_from_str(due_date.trim(), "%Y-%m-%d").is_ok()
|
||||
&& due_date.trim() < today;
|
||||
let payment_status = if balance == 0 && total_paise > 0 {
|
||||
"paid"
|
||||
} else if overdue {
|
||||
"overdue"
|
||||
} else if settled > 0 {
|
||||
"partially_paid"
|
||||
} else {
|
||||
"unpaid"
|
||||
};
|
||||
PaymentSummary {
|
||||
paid_paise,
|
||||
tds_paise,
|
||||
balance_paise: balance,
|
||||
payment_status: payment_status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary for one invoice read from the payments table.
|
||||
pub fn summary_for(
|
||||
conn: &Connection,
|
||||
invoice_id: i64,
|
||||
status: &str,
|
||||
total: f64,
|
||||
due_date: &str,
|
||||
) -> rusqlite::Result<PaymentSummary> {
|
||||
let (paid, tds): (i64, i64) = conn.query_row(
|
||||
"SELECT COALESCE(SUM(amount_paise), 0), COALESCE(SUM(tds_paise), 0)
|
||||
FROM payments WHERE invoice_id = ?1",
|
||||
params![invoice_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)?;
|
||||
Ok(derive_summary(
|
||||
status,
|
||||
rupees_to_paise(total),
|
||||
paid,
|
||||
tds,
|
||||
due_date,
|
||||
&today_ist(),
|
||||
))
|
||||
}
|
||||
|
||||
fn rupees(paise: i64) -> String {
|
||||
format!("₹{}.{:02}", paise / 100, paise % 100)
|
||||
}
|
||||
|
||||
fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
|
||||
Ok(Payment {
|
||||
id: r.get(0)?,
|
||||
invoice_id: r.get(1)?,
|
||||
paid_on: r.get(2)?,
|
||||
amount_paise: r.get(3)?,
|
||||
tds_paise: r.get(4)?,
|
||||
mode: r.get(5)?,
|
||||
reference: r.get(6)?,
|
||||
notes: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
erpnext_payment_entry: r
|
||||
.get::<_, Option<String>>(9)?
|
||||
.filter(|e| !e.trim().is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
const COLS: &str = "id, invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at, erpnext_payment_entry";
|
||||
|
||||
pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result<Payment, String> {
|
||||
let db = |e: rusqlite::Error| e.to_string();
|
||||
|
||||
NaiveDate::parse_from_str(input.paid_on.trim(), "%Y-%m-%d")
|
||||
.map_err(|_| "Payment date must be a date like 2026-04-01".to_string())?;
|
||||
if !MODES.contains(&input.mode.as_str()) {
|
||||
return Err(format!("Unknown payment mode \"{}\"", input.mode));
|
||||
}
|
||||
if input.amount_paise < 0 || input.tds_paise < 0 {
|
||||
return Err("Amounts cannot be negative".into());
|
||||
}
|
||||
if input.amount_paise > MAX_PAISE || input.tds_paise > MAX_PAISE {
|
||||
return Err("That amount is too large".into());
|
||||
}
|
||||
if input.amount_paise + input.tds_paise == 0 {
|
||||
return Err("Enter an amount received or TDS deducted".into());
|
||||
}
|
||||
|
||||
let tx = conn.transaction().map_err(db)?;
|
||||
let (status, total): (String, f64) = tx
|
||||
.query_row(
|
||||
"SELECT status, total FROM invoices WHERE id = ?1",
|
||||
params![input.invoice_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(db)?
|
||||
.ok_or_else(|| "Invoice not found".to_string())?;
|
||||
if status != "issued" {
|
||||
return Err(format!(
|
||||
"Payments can only be recorded on an issued invoice (this one is {status})"
|
||||
));
|
||||
}
|
||||
let total_paise = rupees_to_paise(total);
|
||||
let settled: i64 = tx
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(amount_paise + tds_paise), 0) FROM payments WHERE invoice_id = ?1",
|
||||
params![input.invoice_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(db)?;
|
||||
let balance = total_paise - settled;
|
||||
let incoming = input.amount_paise + input.tds_paise;
|
||||
if incoming > balance {
|
||||
return Err(format!(
|
||||
"This payment of {} (cash plus TDS) is more than the outstanding balance of {}",
|
||||
rupees(incoming),
|
||||
rupees(balance.max(0))
|
||||
));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
tx.execute(
|
||||
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
input.invoice_id,
|
||||
input.paid_on.trim(),
|
||||
input.amount_paise,
|
||||
input.tds_paise,
|
||||
input.mode,
|
||||
input.reference.trim(),
|
||||
input.notes.trim(),
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(db)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
let payment = tx
|
||||
.query_row(
|
||||
&format!("SELECT {COLS} FROM payments WHERE id = ?1"),
|
||||
params![id],
|
||||
map_payment,
|
||||
)
|
||||
.map_err(db)?;
|
||||
tx.commit().map_err(db)?;
|
||||
Ok(payment)
|
||||
}
|
||||
|
||||
pub fn list_payments_impl(conn: &Connection, invoice_id: i64) -> Result<Vec<Payment>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"SELECT {COLS} FROM payments WHERE invoice_id = ?1 ORDER BY paid_on ASC, id ASC"
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(params![invoice_id], map_payment)
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn delete_payment_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||
let changed = conn
|
||||
.execute("DELETE FROM payments WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
if changed == 0 {
|
||||
return Err("Payment not found".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn record_payment(state: State<AppState>, input: PaymentInput) -> Result<Payment, String> {
|
||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
record_payment_impl(&mut conn, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_payments(state: State<AppState>, invoice_id: i64) -> Result<Vec<Payment>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_payments_impl(&conn, invoice_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_payment(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
delete_payment_impl(&conn, id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::invoice::{cancel_invoice_impl, issue_invoice_impl};
|
||||
use crate::models::InvoiceInput;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
/// An unregistered vendor issues without tax, so the invoice total is exactly the line amount.
|
||||
fn issued(conn: &mut Connection, amount: f64, due: &str) -> i64 {
|
||||
let input: InvoiceInput = serde_json::from_value(json!({
|
||||
"invoiceDate": "2026-04-01",
|
||||
"dueDate": due,
|
||||
"clientName": "Client Ltd",
|
||||
"taxType": "none",
|
||||
"taxRate": 0.0,
|
||||
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
|
||||
}))
|
||||
.unwrap();
|
||||
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({}))
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
|
||||
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
|
||||
PaymentInput {
|
||||
invoice_id,
|
||||
paid_on: "2026-04-10".into(),
|
||||
amount_paise: amount,
|
||||
tds_paise: tds,
|
||||
mode: "bank_transfer".into(),
|
||||
reference: "UTR1".into(),
|
||||
notes: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn summary(conn: &Connection, id: i64) -> PaymentSummary {
|
||||
let (status, total, due): (String, f64, String) = conn
|
||||
.query_row(
|
||||
"SELECT status, total, due_date FROM invoices WHERE id = ?1",
|
||||
params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
summary_for(conn, id, &status, total, &due).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_then_full_payment_settles() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||
assert_eq!(summary(&conn, id).payment_status, "unpaid");
|
||||
assert_eq!(summary(&conn, id).balance_paise, 100_000);
|
||||
|
||||
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
|
||||
let s = summary(&conn, id);
|
||||
assert_eq!(
|
||||
(s.payment_status.as_str(), s.paid_paise, s.balance_paise),
|
||||
("partially_paid", 40_000, 60_000)
|
||||
);
|
||||
|
||||
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
|
||||
let s = summary(&conn, id);
|
||||
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("paid", 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tds_counts_towards_settling() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||
// 10% TDS: the client pays 900.00 in cash and deducts 100.00.
|
||||
record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap();
|
||||
let s = summary(&conn, id);
|
||||
assert_eq!(
|
||||
(
|
||||
s.payment_status.as_str(),
|
||||
s.paid_paise,
|
||||
s.tds_paise,
|
||||
s.balance_paise
|
||||
),
|
||||
("paid", 90_000, 10_000, 0)
|
||||
);
|
||||
// TDS alone is a valid payment too.
|
||||
let id2 = issued(&mut conn, 500.0, "2999-01-01");
|
||||
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
|
||||
assert_eq!(summary(&conn, id2).payment_status, "partially_paid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overpayment_is_rejected_including_tds() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
|
||||
assert!(
|
||||
err.contains("more than the outstanding balance of ₹1000.00"),
|
||||
"{err}"
|
||||
);
|
||||
// Cash plus TDS is what counts.
|
||||
assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err());
|
||||
record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap();
|
||||
let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err();
|
||||
assert!(err.contains("₹500.00"), "{err}");
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_is_rejected() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||
assert!(record_payment_impl(&mut conn, pay(id, 0, 0)).is_err());
|
||||
assert!(record_payment_impl(&mut conn, pay(id, -1, 0)).is_err());
|
||||
assert!(record_payment_impl(&mut conn, pay(id, 100, -5)).is_err());
|
||||
let mut bad = pay(id, 100, 0);
|
||||
bad.mode = "barter".into();
|
||||
assert!(record_payment_impl(&mut conn, bad).is_err());
|
||||
let mut bad = pay(id, 100, 0);
|
||||
bad.paid_on = "10/04/2026".into();
|
||||
assert!(record_payment_impl(&mut conn, bad).is_err());
|
||||
assert!(record_payment_impl(&mut conn, pay(9999, 100, 0)).is_err());
|
||||
// The table CHECKs hold even if a caller bypasses the command validation.
|
||||
assert!(conn
|
||||
.execute(
|
||||
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, created_at)
|
||||
VALUES (?1, '2026-04-10', 0, 0, 'cash', 'now')",
|
||||
params![id]
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_and_cancelled_invoices_take_no_payments() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||
cancel_invoice_impl(&mut conn, id, "mistake").unwrap();
|
||||
let err = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap_err();
|
||||
assert!(err.contains("issued invoice"), "{err}");
|
||||
let s = summary(&conn, id);
|
||||
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
|
||||
|
||||
let id2 = issued(&mut conn, 1000.0, "2999-01-01");
|
||||
conn.execute(
|
||||
"UPDATE invoices SET status = 'draft' WHERE id = ?1",
|
||||
params![id2],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invoice_with_payments_cannot_be_cancelled() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||
let p = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap();
|
||||
let err = cancel_invoice_impl(&mut conn, id, "").unwrap_err();
|
||||
assert!(err.contains("payments"), "{err}");
|
||||
delete_payment_impl(&conn, p.id).unwrap();
|
||||
cancel_invoice_impl(&mut conn, id, "").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_payment_reverts_the_status() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||
let full = record_payment_impl(&mut conn, pay(id, 100_000, 0)).unwrap();
|
||||
assert_eq!(summary(&conn, id).payment_status, "paid");
|
||||
delete_payment_impl(&conn, full.id).unwrap();
|
||||
let s = summary(&conn, id);
|
||||
assert_eq!(
|
||||
(s.payment_status.as_str(), s.balance_paise),
|
||||
("unpaid", 100_000)
|
||||
);
|
||||
assert!(list_payments_impl(&conn, id).unwrap().is_empty());
|
||||
assert!(delete_payment_impl(&conn, full.id).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overdue_is_derived_from_due_date_and_balance() {
|
||||
let d = |due: &str, paid: i64, tds: i64| {
|
||||
derive_summary("issued", 100_000, paid, tds, due, "2026-05-10")
|
||||
};
|
||||
// Due today is not overdue yet; the day after is.
|
||||
assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid");
|
||||
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
|
||||
// Overdue wins over partially paid; the paid amount is still reported.
|
||||
let s = d("2026-05-09", 10_000, 0);
|
||||
assert_eq!(
|
||||
(s.payment_status.as_str(), s.paid_paise),
|
||||
("overdue", 10_000)
|
||||
);
|
||||
// A settled invoice is never overdue.
|
||||
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
|
||||
// No usable due date means never overdue.
|
||||
assert_eq!(d("", 0, 0).payment_status, "unpaid");
|
||||
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
|
||||
// Cancelled invoices are not owed.
|
||||
assert_eq!(
|
||||
derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_are_integer_paise_without_float_drift() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
// 0.1 + 0.2 style amounts: 3 x 33.33 = 99.99.
|
||||
let id = issued(&mut conn, 99.99, "2999-01-01");
|
||||
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
||||
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
||||
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
||||
let s = summary(&conn, id);
|
||||
assert_eq!(
|
||||
(s.paid_paise, s.balance_paise, s.payment_status.as_str()),
|
||||
(9999, 0, "paid")
|
||||
);
|
||||
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
|
||||
assert_eq!(inv.total_paise, 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_invoices_joins_the_payment_sums() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let a = issued(&mut conn, 1000.0, "2020-01-01");
|
||||
let b = issued(&mut conn, 200.0, "2999-01-01");
|
||||
record_payment_impl(&mut conn, pay(a, 30_000, 5_000)).unwrap();
|
||||
record_payment_impl(&mut conn, pay(a, 10_000, 0)).unwrap();
|
||||
record_payment_impl(&mut conn, pay(b, 20_000, 0)).unwrap();
|
||||
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
|
||||
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
|
||||
let ra = get(a);
|
||||
assert_eq!(
|
||||
(
|
||||
ra.payment.paid_paise,
|
||||
ra.payment.tds_paise,
|
||||
ra.payment.balance_paise
|
||||
),
|
||||
(40_000, 5_000, 55_000)
|
||||
);
|
||||
assert_eq!(ra.payment.payment_status, "overdue");
|
||||
assert_eq!(ra.total_paise, 100_000);
|
||||
assert_eq!(get(b).payment.payment_status, "paid");
|
||||
// The JSON keeps the existing fields and adds the flattened summary.
|
||||
let json = serde_json::to_value(ra).unwrap();
|
||||
assert_eq!(json["balancePaise"], 55_000);
|
||||
assert_eq!(json["paymentStatus"], "overdue");
|
||||
assert_eq!(json["total"], 1000.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use crate::models::ItemPreset;
|
||||
use crate::AppState;
|
||||
use rusqlite::{params, Connection, Row};
|
||||
use tauri::State;
|
||||
|
||||
const MODES: &[&str] = &["fixed", "rate"];
|
||||
const UNITS: &[&str] = &["second", "minute", "hour", "session", "unit"];
|
||||
/// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow.
|
||||
const MAX_RATE_PAISE: i64 = 100_000_000_000;
|
||||
|
||||
const COLS: &str = "id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at";
|
||||
|
||||
fn map_preset(row: &Row) -> rusqlite::Result<ItemPreset> {
|
||||
Ok(ItemPreset {
|
||||
id: Some(row.get(0)?),
|
||||
description: row.get(1)?,
|
||||
hsn_sac: row.get(2)?,
|
||||
mode: row.get(3)?,
|
||||
unit: row.get(4)?,
|
||||
rate_paise: row.get(5)?,
|
||||
sort_order: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_presets_impl(conn: &Connection) -> Result<Vec<ItemPreset>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"SELECT {COLS} FROM item_presets ORDER BY sort_order ASC, description COLLATE NOCASE ASC, id ASC"
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], map_preset)
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPreset, String> {
|
||||
let description = preset.description.trim().to_string();
|
||||
if description.is_empty() {
|
||||
return Err("Preset description is required".into());
|
||||
}
|
||||
if !MODES.contains(&preset.mode.as_str()) {
|
||||
return Err(format!("Unknown line type \"{}\"", preset.mode));
|
||||
}
|
||||
if !UNITS.contains(&preset.unit.as_str()) {
|
||||
return Err(format!("Unknown unit \"{}\"", preset.unit));
|
||||
}
|
||||
if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) {
|
||||
return Err("Amount is out of range".into());
|
||||
}
|
||||
let hsn = preset.hsn_sac.trim().to_string();
|
||||
let id = match preset.id {
|
||||
Some(id) => {
|
||||
let n = conn
|
||||
.execute(
|
||||
"UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6
|
||||
WHERE id=?7",
|
||||
params![description, hsn, preset.mode, preset.unit, preset.rate_paise, preset.sort_order, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Err("Preset not found".into());
|
||||
}
|
||||
id
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
description,
|
||||
hsn,
|
||||
preset.mode,
|
||||
preset.unit,
|
||||
preset.rate_paise,
|
||||
preset.sort_order,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
};
|
||||
conn.query_row(
|
||||
&format!("SELECT {COLS} FROM item_presets WHERE id = ?1"),
|
||||
params![id],
|
||||
map_preset,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn delete_preset_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM item_presets WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_item_presets(state: State<AppState>) -> Result<Vec<ItemPreset>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_presets_impl(&conn)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_item_preset(state: State<AppState>, preset: ItemPreset) -> Result<ItemPreset, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
save_preset_impl(&conn, preset)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_item_preset(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
delete_preset_impl(&conn, id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::open_in_memory;
|
||||
|
||||
fn preset(desc: &str) -> ItemPreset {
|
||||
serde_json::from_value(serde_json::json!({ "id": null, "description": desc })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crud_round_trip_in_integer_paise() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let mut p = preset(" Voice-over session ");
|
||||
p.mode = "rate".into();
|
||||
p.unit = "hour".into();
|
||||
p.rate_paise = 150_050;
|
||||
p.hsn_sac = " 998397 ".into();
|
||||
let saved = save_preset_impl(&conn, p).unwrap();
|
||||
assert_eq!(saved.description, "Voice-over session");
|
||||
assert_eq!(saved.hsn_sac, "998397");
|
||||
assert_eq!(
|
||||
(saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise),
|
||||
("rate", "hour", 150_050)
|
||||
);
|
||||
|
||||
let mut edit = saved.clone();
|
||||
edit.rate_paise = 200_000;
|
||||
let again = save_preset_impl(&conn, edit).unwrap();
|
||||
assert_eq!(again.id, saved.id);
|
||||
assert_eq!(again.rate_paise, 200_000);
|
||||
|
||||
save_preset_impl(&conn, preset("Another")).unwrap();
|
||||
let listed = list_presets_impl(&conn).unwrap();
|
||||
assert_eq!(listed.len(), 2);
|
||||
// Same sort_order: alphabetical.
|
||||
assert_eq!(listed[0].description, "Another");
|
||||
|
||||
delete_preset_impl(&conn, saved.id.unwrap()).unwrap();
|
||||
assert_eq!(list_presets_impl(&conn).unwrap().len(), 1);
|
||||
// Deleting a missing id is a no-op.
|
||||
delete_preset_impl(&conn, 12345).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_bad_presets() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
assert!(save_preset_impl(&conn, preset(" ")).is_err());
|
||||
let mut p = preset("X");
|
||||
p.rate_paise = -1;
|
||||
assert!(save_preset_impl(&conn, p).is_err());
|
||||
let mut p = preset("X");
|
||||
p.mode = "weird".into();
|
||||
assert!(save_preset_impl(&conn, p).is_err());
|
||||
let mut p = preset("X");
|
||||
p.unit = "decade".into();
|
||||
assert!(save_preset_impl(&conn, p).is_err());
|
||||
let mut ghost = preset("Ghost");
|
||||
ghost.id = Some(77);
|
||||
assert!(save_preset_impl(&conn, ghost).is_err());
|
||||
assert!(list_presets_impl(&conn).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,11 @@ mod tests {
|
||||
assert_eq!(s.prefix, "FY27/");
|
||||
assert_eq!(s.next_invoice_number, "FY27/0001");
|
||||
let active: i64 = c
|
||||
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM invoice_series WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active, 1);
|
||||
}
|
||||
@@ -163,12 +167,20 @@ mod tests {
|
||||
fn invalid_series_leaves_the_active_one_alone() {
|
||||
let mut c = conn();
|
||||
let before: i64 = c
|
||||
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT id FROM invoice_series WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err());
|
||||
assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err());
|
||||
let after: i64 = c
|
||||
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT id FROM invoice_series WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
@@ -184,7 +196,11 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
|
||||
let active: i64 = c
|
||||
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM invoice_series WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active, 1);
|
||||
}
|
||||
|
||||
@@ -142,8 +142,14 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
|
||||
r#"UPDATE bank_accounts SET label=?1, bank_name=?2, account_name=?3,
|
||||
account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#,
|
||||
params![
|
||||
bank.label, bank.bank_name, bank.account_name, bank.account_no,
|
||||
bank.branch, bank.ifsc, is_default, id
|
||||
bank.label,
|
||||
bank.bank_name,
|
||||
bank.account_name,
|
||||
bank.account_no,
|
||||
bank.branch,
|
||||
bank.ifsc,
|
||||
is_default,
|
||||
id
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -155,8 +161,13 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
|
||||
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
|
||||
params![
|
||||
bank.label, bank.bank_name, bank.account_name, bank.account_no,
|
||||
bank.branch, bank.ifsc, is_default
|
||||
bank.label,
|
||||
bank.bank_name,
|
||||
bank.account_name,
|
||||
bank.account_no,
|
||||
bank.branch,
|
||||
bank.ifsc,
|
||||
is_default
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -208,10 +219,16 @@ pub fn delete_bank_impl(conn: &mut Connection, id: i64) -> Result<(), String> {
|
||||
tx.execute("UPDATE bank_accounts SET is_default = 0", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(next) = next {
|
||||
tx.execute("UPDATE bank_accounts SET is_default = 1 WHERE id = ?1", params![next])
|
||||
tx.execute(
|
||||
"UPDATE bank_accounts SET is_default = 1 WHERE id = ?1",
|
||||
params![next],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
tx.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![next])
|
||||
tx.execute(
|
||||
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
||||
params![next],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
tx.commit().map_err(|e| e.to_string())
|
||||
@@ -238,12 +255,20 @@ mod tests {
|
||||
|
||||
fn default_state(conn: &Connection) -> (Option<i64>, Vec<i64>) {
|
||||
let setting = conn
|
||||
.query_row("SELECT default_bank_id FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT default_bank_id FROM app_settings WHERE id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id")
|
||||
.unwrap();
|
||||
let flagged = stmt.query_map([], |r| r.get(0)).unwrap().map(Result::unwrap).collect();
|
||||
let flagged = stmt
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
(setting, flagged)
|
||||
}
|
||||
|
||||
@@ -259,7 +284,10 @@ mod tests {
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(read(&conn).render_prefs, "");
|
||||
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"])
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET render_prefs = ?1 WHERE id = 1",
|
||||
params!["{\"version\":1}"],
|
||||
)
|
||||
.unwrap();
|
||||
let settings = read(&conn);
|
||||
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
||||
|
||||
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||
|
||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||
const LATEST_VERSION: i64 = 6;
|
||||
pub(crate) const LATEST_VERSION: i64 = 11;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
@@ -217,8 +217,148 @@ CREATE INDEX idx_user_fonts_sha ON user_fonts(sha256);
|
||||
CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0;
|
||||
"#;
|
||||
|
||||
/// Version 7: structured client addresses, GST category and per-client defaults, plus saved item presets.
|
||||
/// `clients.address` stays the composed multi-line string (issued invoices and the PDF read it); the new
|
||||
/// parts are an editing aid. The address state is `clients.state_code`, which is also the default place
|
||||
/// of supply, so there is no second state column. `payment_terms_days` NULL means "use the settings default".
|
||||
/// `item_presets.rate_paise` is the fixed amount for mode 'fixed' and the per-unit rate for mode 'rate'.
|
||||
const M7: &str = r#"
|
||||
ALTER TABLE clients ADD COLUMN address_line1 TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN address_line2 TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN city TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN pincode TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN gst_category TEXT NOT NULL DEFAULT 'unregistered'
|
||||
CHECK (gst_category IN ('registered_regular', 'unregistered', 'composition', 'sez', 'overseas'));
|
||||
ALTER TABLE clients ADD COLUMN default_notes TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN payment_terms_days INTEGER;
|
||||
UPDATE clients SET gst_category = 'registered_regular' WHERE gstin NOT IN ('', 'NA');
|
||||
|
||||
CREATE TABLE item_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
description TEXT NOT NULL,
|
||||
hsn_sac TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'fixed' CHECK (mode IN ('fixed', 'rate')),
|
||||
unit TEXT NOT NULL DEFAULT 'unit' CHECK (unit IN ('second', 'minute', 'hour', 'session', 'unit')),
|
||||
rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"#;
|
||||
|
||||
/// Version 8: payments received against issued invoices. Amounts are integer paise. `amount_paise` is the cash
|
||||
/// received and `tds_paise` the tax the client deducted at source; both count towards settling the invoice.
|
||||
/// Payment status is derived from these rows and the due date, never stored, and the issued invoice row is untouched.
|
||||
const M8: &str = r#"
|
||||
CREATE TABLE payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invoice_id INTEGER NOT NULL REFERENCES invoices(id),
|
||||
paid_on TEXT NOT NULL,
|
||||
amount_paise INTEGER NOT NULL DEFAULT 0 CHECK (amount_paise >= 0),
|
||||
tds_paise INTEGER NOT NULL DEFAULT 0 CHECK (tds_paise >= 0),
|
||||
mode TEXT NOT NULL CHECK (mode IN ('bank_transfer', 'upi', 'cash', 'cheque', 'other')),
|
||||
reference TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
CHECK (amount_paise + tds_paise > 0)
|
||||
);
|
||||
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
|
||||
"#;
|
||||
|
||||
/// Version 9: `auto_backup` switches the daily automatic backup (see commands::backup). On by default. It is read and
|
||||
/// written through its own commands, not through `Settings`, so a settings save can never clobber it.
|
||||
const M9: &str = r#"
|
||||
ALTER TABLE app_settings ADD COLUMN auto_backup INTEGER NOT NULL DEFAULT 1;
|
||||
"#;
|
||||
|
||||
/// Version 10: ERPNext integration. `erpnext_config` is a single row (inserted here, so reading it is a plain SELECT)
|
||||
/// holding the connection, the company/account/master mapping and the toggles. `api_secret` is stored here and is never
|
||||
/// returned to the webview. `erpnext_sync` tracks what was pushed per invoice; issued invoices themselves are untouched.
|
||||
/// The remote names on clients, payments and item presets are nullable: NULL means "not linked yet".
|
||||
const M10: &str = r#"
|
||||
CREATE TABLE erpnext_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
api_secret TEXT NOT NULL DEFAULT '',
|
||||
extra_ca_pem TEXT NOT NULL DEFAULT '',
|
||||
company TEXT NOT NULL DEFAULT '',
|
||||
company_address TEXT NOT NULL DEFAULT '',
|
||||
naming_mode TEXT NOT NULL DEFAULT 'mirror' CHECK (naming_mode IN ('mirror', 'series')),
|
||||
naming_series TEXT NOT NULL DEFAULT '',
|
||||
income_account TEXT NOT NULL DEFAULT '',
|
||||
cost_center TEXT NOT NULL DEFAULT '',
|
||||
cgst_account TEXT NOT NULL DEFAULT '',
|
||||
sgst_account TEXT NOT NULL DEFAULT '',
|
||||
utgst_account TEXT NOT NULL DEFAULT '',
|
||||
igst_account TEXT NOT NULL DEFAULT '',
|
||||
tax_template_intra TEXT NOT NULL DEFAULT '',
|
||||
tax_template_inter TEXT NOT NULL DEFAULT '',
|
||||
payment_bank_account TEXT NOT NULL DEFAULT '',
|
||||
tds_account TEXT NOT NULL DEFAULT '',
|
||||
default_item_code TEXT NOT NULL DEFAULT '',
|
||||
uom_map TEXT NOT NULL DEFAULT '{"second":"Second","minute":"Minute","hour":"Hour","session":"Nos","unit":"Nos"}',
|
||||
customer_group TEXT NOT NULL DEFAULT '',
|
||||
territory TEXT NOT NULL DEFAULT '',
|
||||
selling_price_list TEXT NOT NULL DEFAULT '',
|
||||
submit_on_push INTEGER NOT NULL DEFAULT 0,
|
||||
attach_pdf INTEGER NOT NULL DEFAULT 1,
|
||||
auto_push_on_issue INTEGER NOT NULL DEFAULT 0,
|
||||
create_missing_customers INTEGER NOT NULL DEFAULT 1,
|
||||
last_detect_result TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT
|
||||
);
|
||||
INSERT INTO erpnext_config (id) VALUES (1);
|
||||
|
||||
CREATE TABLE erpnext_sync (
|
||||
invoice_id INTEGER PRIMARY KEY REFERENCES invoices(id),
|
||||
remote_name TEXT NOT NULL DEFAULT '',
|
||||
remote_docstatus INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'error')),
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
payload_hash TEXT NOT NULL DEFAULT '',
|
||||
synced_at TEXT
|
||||
);
|
||||
|
||||
ALTER TABLE clients ADD COLUMN erpnext_customer TEXT;
|
||||
ALTER TABLE clients ADD COLUMN erpnext_address TEXT;
|
||||
ALTER TABLE payments ADD COLUMN erpnext_payment_entry TEXT;
|
||||
ALTER TABLE item_presets ADD COLUMN erpnext_item_code TEXT;
|
||||
"#;
|
||||
|
||||
/// Version 11: ERPNext push state. `erpnext_sync.status` gains `conflict` (the remote document exists with a different
|
||||
/// total; we never overwrite it) and `attachment_sha256` records which archived PDF was uploaded so it is never attached
|
||||
/// twice. SQLite cannot alter a CHECK constraint, so the table is rebuilt; M10 rows are copied over.
|
||||
const M11: &str = r#"
|
||||
CREATE TABLE erpnext_sync_new (
|
||||
invoice_id INTEGER PRIMARY KEY REFERENCES invoices(id),
|
||||
remote_name TEXT NOT NULL DEFAULT '',
|
||||
remote_docstatus INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'error', 'conflict')),
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
payload_hash TEXT NOT NULL DEFAULT '',
|
||||
synced_at TEXT,
|
||||
attachment_sha256 TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO erpnext_sync_new (invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at)
|
||||
SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync;
|
||||
DROP TABLE erpnext_sync;
|
||||
ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync;
|
||||
"#;
|
||||
|
||||
fn migrations() -> Migrations<'static> {
|
||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)])
|
||||
Migrations::new(vec![
|
||||
M::up(SCHEMA),
|
||||
M::up(M2),
|
||||
M::up(M3),
|
||||
M::up(M4),
|
||||
M::up(M5),
|
||||
M::up(M6),
|
||||
M::up(M7),
|
||||
M::up(M8),
|
||||
M::up(M9),
|
||||
M::up(M10),
|
||||
M::up(M11),
|
||||
])
|
||||
}
|
||||
|
||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||
@@ -238,7 +378,14 @@ pub fn open(path: &Path, backup_dir: &Path) -> Result<Connection, DbError> {
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
/// Write a consistent, compacted snapshot of the open database to `target` (which must not exist yet).
|
||||
/// Safe while other connections read and write: SQLite takes a read transaction for the copy.
|
||||
pub(crate) fn vacuum_into(conn: &Connection, target: &Path) -> rusqlite::Result<()> {
|
||||
let quoted = target.to_string_lossy().replace('\'', "''");
|
||||
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))
|
||||
}
|
||||
|
||||
pub(crate) fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
||||
[],
|
||||
@@ -256,8 +403,7 @@ fn backup_before_migration(
|
||||
fs::create_dir_all(backup_dir)?;
|
||||
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
||||
let quoted = target.to_string_lossy().replace('\'', "''");
|
||||
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
|
||||
vacuum_into(conn, &target)?;
|
||||
// A failed prune must not block startup; the backup itself already succeeded.
|
||||
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
||||
Ok(target)
|
||||
@@ -290,8 +436,8 @@ fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
/// Insert the first-run defaults the very first time the app is opened.
|
||||
/// Vendor details are pre-filled from the owner's existing invoice. The vendor
|
||||
/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax).
|
||||
/// The defaults are neutral: no vendor, bank or tax identity is pre-filled, so the setup wizard
|
||||
/// starts blank. The vendor is an unregistered supplier (no tax) until the wizard says otherwise.
|
||||
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let settings_count: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
||||
@@ -301,38 +447,8 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
||||
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
||||
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
||||
params![
|
||||
"Test Vendor",
|
||||
"1 Test Road, Bangalore - 560001",
|
||||
"vendor@example.com",
|
||||
"9000000000",
|
||||
"ABCDE1234F",
|
||||
"",
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
let bank_count: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM bank_accounts", [], |r| r.get(0))?;
|
||||
if bank_count == 0 {
|
||||
conn.execute(
|
||||
r#"INSERT INTO bank_accounts
|
||||
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)"#,
|
||||
params![
|
||||
"Primary",
|
||||
"State Bank of India",
|
||||
"Test Vendor",
|
||||
"000000000000",
|
||||
"000001",
|
||||
"TEST0000001",
|
||||
],
|
||||
)?;
|
||||
let bank_id = conn.last_insert_rowid();
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
||||
params![bank_id],
|
||||
VALUES (1, '', '', '', '', '', '', '', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -351,6 +467,28 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sample vendor and bank for command-logic unit tests (production seeds none). Fake values only.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn seed_test_fixture(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
r#"UPDATE app_settings SET vendor_name = 'Test Vendor', vendor_address = '1 Test Road, Bangalore - 560001',
|
||||
vendor_email = 'vendor@example.com', vendor_phone = '9000000000', vendor_pan = 'ABCDE1234F',
|
||||
vendor_state_code = '29' WHERE id = 1"#,
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
r#"INSERT INTO bank_accounts (label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
||||
VALUES ('Primary', 'Test Bank', 'Test Vendor', '000000000000', '000001', 'TEST0000001', 1)"#,
|
||||
[],
|
||||
)?;
|
||||
let bank_id = conn.last_insert_rowid();
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
||||
params![bank_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A migrated, seeded in-memory database for unit tests of the command logic.
|
||||
#[cfg(test)]
|
||||
pub fn open_in_memory() -> Result<Connection, DbError> {
|
||||
@@ -358,6 +496,7 @@ pub fn open_in_memory() -> Result<Connection, DbError> {
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
migrations().to_latest(&mut conn)?;
|
||||
seed(&conn)?;
|
||||
seed_test_fixture(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
@@ -406,8 +545,9 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R
|
||||
}
|
||||
|
||||
let snapshots: Vec<(i64, String)> = {
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL")?;
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
@@ -438,7 +578,12 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R
|
||||
|
||||
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
|
||||
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
||||
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
|
||||
format!(
|
||||
"{}{:0width$}",
|
||||
prefix,
|
||||
number,
|
||||
width = padding.max(1) as usize
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -546,7 +691,8 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
"#;
|
||||
|
||||
fn user_version(conn: &Connection) -> i64 {
|
||||
conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap()
|
||||
conn.pragma_query_value(None, "user_version", |r| r.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn backups_in(dir: &Path) -> Vec<PathBuf> {
|
||||
@@ -580,7 +726,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
.unwrap();
|
||||
assert_eq!(tax_type, "none");
|
||||
assert_eq!(gst_reg, "unregistered");
|
||||
assert_eq!(name, "Test Vendor");
|
||||
assert_eq!(name, "");
|
||||
assert!(backups_in(&backups).is_empty());
|
||||
}
|
||||
|
||||
@@ -588,7 +734,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
fn m4_adds_archive_and_export_dir_columns() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let dir: String = conn
|
||||
.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT last_export_dir FROM app_settings WHERE id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(dir, "");
|
||||
// The columns exist and are nullable on invoices (no rows needed to prove it).
|
||||
@@ -609,14 +759,26 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
fn render_prefs_column_defaults_to_empty_and_round_trips() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let initial: String = conn
|
||||
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT render_prefs FROM app_settings WHERE id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(initial, "");
|
||||
|
||||
let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#;
|
||||
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", [json]).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET render_prefs = ?1 WHERE id = 1",
|
||||
[json],
|
||||
)
|
||||
.unwrap();
|
||||
let stored: String = conn
|
||||
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT render_prefs FROM app_settings WHERE id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored, json);
|
||||
}
|
||||
@@ -643,20 +805,283 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
.to_latest(&mut old)
|
||||
.unwrap();
|
||||
seed(&old).unwrap();
|
||||
old.execute("UPDATE app_settings SET logo_path = 'assets/logo-old.png'", []).unwrap();
|
||||
old.execute(
|
||||
"UPDATE app_settings SET logo_path = 'assets/logo-old.png'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(user_version(&old), 4);
|
||||
}
|
||||
let conn = open(&path, &dir.path().join("backups")).unwrap();
|
||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||
let (logo, meta): (String, String) = conn
|
||||
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.query_row(
|
||||
"SELECT logo_path, logo_meta FROM app_settings WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", ""));
|
||||
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m7_fresh_database_has_client_and_preset_columns() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
conn.prepare(
|
||||
"SELECT address_line1, address_line2, city, pincode, gst_category, default_notes, payment_terms_days FROM clients",
|
||||
)
|
||||
.unwrap();
|
||||
conn.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at FROM item_presets")
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO clients (name, created_at) VALUES ('X', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let (cat, terms): (String, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT gst_category, payment_terms_days FROM clients",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((cat.as_str(), terms), ("unregistered", None));
|
||||
// The category CHECK rejects unknown values.
|
||||
assert!(conn
|
||||
.execute("UPDATE clients SET gst_category = 'bogus'", [])
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let all = vec![
|
||||
M::up(SCHEMA),
|
||||
M::up(M2),
|
||||
M::up(M3),
|
||||
M::up(M4),
|
||||
M::up(M5),
|
||||
M::up(M6),
|
||||
];
|
||||
Migrations::new(all).to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 6);
|
||||
conn.execute(
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||
VALUES ('Acme', 'Line one\nLine two', '29ABCDE1234F1Z5', '29', 'PO-1', 'now'),
|
||||
('Plain', '', '', '', '', 'now'),
|
||||
('Na', '', 'NA', '', '', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrations().to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||
|
||||
type Row = (String, String, String, String, String, String, String);
|
||||
let rows: Vec<Row> = conn
|
||||
.prepare("SELECT name, address, gstin, state_code, po_number, gst_category, address_line1 FROM clients ORDER BY id")
|
||||
.unwrap()
|
||||
.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(rows[0].1, "Line one\nLine two");
|
||||
assert_eq!(
|
||||
(rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()),
|
||||
("29ABCDE1234F1Z5", "29", "PO-1")
|
||||
);
|
||||
assert_eq!(rows[0].5, "registered_regular");
|
||||
assert_eq!(rows[0].6, "");
|
||||
assert_eq!(rows[1].5, "unregistered");
|
||||
assert_eq!(rows[2].5, "unregistered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_fresh_database_has_the_erpnext_tables_and_a_default_config_row() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let (naming, submit, attach, auto_push, create_customers, secret, uom): (String, i64, i64, i64, i64, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT naming_mode, submit_on_push, attach_pdf, auto_push_on_issue, create_missing_customers, api_secret, uom_map
|
||||
FROM erpnext_config WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(naming.as_str(), submit, attach, auto_push, create_customers),
|
||||
("mirror", 0, 1, 0, 1)
|
||||
);
|
||||
assert_eq!(secret, "");
|
||||
assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok());
|
||||
let rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(rows, 1);
|
||||
// Single row only.
|
||||
assert!(conn
|
||||
.execute("INSERT INTO erpnext_config (id) VALUES (2)", [])
|
||||
.is_err());
|
||||
conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync")
|
||||
.unwrap();
|
||||
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients")
|
||||
.unwrap();
|
||||
conn.prepare("SELECT erpnext_payment_entry FROM payments")
|
||||
.unwrap();
|
||||
conn.prepare("SELECT erpnext_item_code FROM item_presets")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let v9 = vec![
|
||||
M::up(SCHEMA),
|
||||
M::up(M2),
|
||||
M::up(M3),
|
||||
M::up(M4),
|
||||
M::up(M5),
|
||||
M::up(M6),
|
||||
M::up(M7),
|
||||
M::up(M8),
|
||||
M::up(M9),
|
||||
];
|
||||
Migrations::new(v9).to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 9);
|
||||
conn.execute("INSERT INTO clients (name, gstin, created_at) VALUES ('Acme', '29ABCDE1234F1Z5', 'now')", []).unwrap();
|
||||
conn.execute("INSERT INTO item_presets (description, rate_paise, created_at) VALUES ('Design', 500000, 'now')", []).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO payments (invoice_id, paid_on, amount_paise, mode, created_at) VALUES (1, '2026-04-10', 12345, 'upi', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrations().to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||
|
||||
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) =
|
||||
conn.query_row(
|
||||
"SELECT name, gstin, erpnext_customer, erpnext_address FROM clients",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(name.as_str(), gstin.as_str(), customer, address),
|
||||
("Acme", "29ABCDE1234F1Z5", None, None)
|
||||
);
|
||||
let (desc, rate, code): (String, i64, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT description, rate_paise, erpnext_item_code FROM item_presets",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
|
||||
let (paid, entry): (i64, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT amount_paise, erpnext_payment_entry FROM payments",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((paid, entry), (12345, None));
|
||||
let number: String = conn
|
||||
.query_row("SELECT number FROM invoices", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(number, "INV/2026-001");
|
||||
|
||||
// The config row exists with defaults, and a sync row can reference the invoice.
|
||||
let enabled: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM erpnext_config WHERE id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(enabled, 1);
|
||||
conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap();
|
||||
assert!(conn
|
||||
.execute(
|
||||
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')",
|
||||
[]
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let v10 = vec![
|
||||
M::up(SCHEMA),
|
||||
M::up(M2),
|
||||
M::up(M3),
|
||||
M::up(M4),
|
||||
M::up(M5),
|
||||
M::up(M6),
|
||||
M::up(M7),
|
||||
M::up(M8),
|
||||
M::up(M9),
|
||||
M::up(M10),
|
||||
];
|
||||
Migrations::new(v10).to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 10);
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO erpnext_sync (invoice_id, remote_name, remote_docstatus, status, payload_hash, synced_at)
|
||||
VALUES (1, 'INV/2026-001', 1, 'synced', 'abc', '2026-04-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(conn
|
||||
.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
|
||||
.is_err());
|
||||
|
||||
migrations().to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 11);
|
||||
let row: (String, i64, String, String, String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
row,
|
||||
(
|
||||
"INV/2026-001".into(),
|
||||
1,
|
||||
"synced".into(),
|
||||
"abc".into(),
|
||||
String::new(),
|
||||
Some("2026-04-02T00:00:00Z".into())
|
||||
)
|
||||
);
|
||||
conn.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
|
||||
.unwrap();
|
||||
assert!(conn
|
||||
.execute("UPDATE erpnext_sync SET status = 'bogus'", [])
|
||||
.is_err());
|
||||
// The unique key still guards one row per invoice.
|
||||
assert!(conn
|
||||
.execute(
|
||||
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')",
|
||||
[]
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
fn build_v1_database(path: &Path) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||
@@ -707,7 +1132,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
let files = backups_in(&backups);
|
||||
assert_eq!(files.len(), 1);
|
||||
let name = files[0].file_name().unwrap().to_str().unwrap();
|
||||
assert!(name.starts_with("voiced-pre-v0-") && name.ends_with(".db"), "{name}");
|
||||
assert!(
|
||||
name.starts_with("voiced-pre-v0-") && name.ends_with(".db"),
|
||||
"{name}"
|
||||
);
|
||||
let backup = Connection::open(&files[0]).unwrap();
|
||||
assert_eq!(user_version(&backup), 0);
|
||||
let n: i64 = backup
|
||||
@@ -728,7 +1156,13 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
assert_eq!(vendor, "Acme Studio");
|
||||
|
||||
// Invoice backfill.
|
||||
let (origin, snapshot, doc_type, number, total): (String, Option<String>, String, String, f64) = conn
|
||||
let (origin, snapshot, doc_type, number, total): (
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
String,
|
||||
f64,
|
||||
) = conn
|
||||
.query_row(
|
||||
"SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices",
|
||||
[],
|
||||
@@ -796,7 +1230,9 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names.len(), MAX_BACKUPS);
|
||||
assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104")));
|
||||
assert!(names
|
||||
.iter()
|
||||
.all(|n| !n.contains("20260101") && !n.contains("20260104")));
|
||||
assert!(dir.path().join("unrelated.db").exists());
|
||||
}
|
||||
|
||||
@@ -805,8 +1241,16 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
let dir = tempdir().unwrap();
|
||||
let data = dir.path();
|
||||
let conn = open(&data.join("voiced.db"), &data.join("backups")).unwrap();
|
||||
let abs_logo = data.join("assets").join("logo-1.png").to_string_lossy().into_owned();
|
||||
let abs_sig = data.join("assets").join("signature-2.jpg").to_string_lossy().into_owned();
|
||||
let abs_logo = data
|
||||
.join("assets")
|
||||
.join("logo-1.png")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let abs_sig = data
|
||||
.join("assets")
|
||||
.join("signature-2.jpg")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let foreign = "/somewhere/else/logo.png";
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET logo_path = ?1, signature_path = ?2",
|
||||
@@ -835,9 +1279,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
for _ in 0..2 {
|
||||
relativize_asset_paths(&conn, data).unwrap();
|
||||
let (logo, sig): (String, String) = conn
|
||||
.query_row("SELECT logo_path, signature_path FROM app_settings", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.query_row(
|
||||
"SELECT logo_path, signature_path FROM app_settings",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(logo, "assets/logo-1.png");
|
||||
assert_eq!(sig, foreign);
|
||||
@@ -854,7 +1300,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
assert_eq!(json["signaturePath"], "assets/signature-2.jpg");
|
||||
assert_eq!(json["vendorName"], "Acme");
|
||||
let bad: String = conn
|
||||
.query_row("SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'", [], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bad, "not json");
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub const STATES: &[(&str, &str)] = &[
|
||||
("09", "Uttar Pradesh"),
|
||||
("10", "Bihar"),
|
||||
("11", "Sikkim"),
|
||||
("12", "Sampleachal Pradesh"),
|
||||
("12", "Arunachal Pradesh"),
|
||||
("13", "Nagaland"),
|
||||
("14", "Manipur"),
|
||||
("15", "Mizoram"),
|
||||
@@ -75,7 +75,11 @@ pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str
|
||||
if registration == "unregistered" {
|
||||
return TaxType::None;
|
||||
}
|
||||
let pos = if pos_state.trim().is_empty() { supplier_state } else { pos_state };
|
||||
let pos = if pos_state.trim().is_empty() {
|
||||
supplier_state
|
||||
} else {
|
||||
pos_state
|
||||
};
|
||||
if pos == supplier_state {
|
||||
TaxType::CgstSgst
|
||||
} else {
|
||||
@@ -225,8 +229,26 @@ pub fn compute_totals(
|
||||
}
|
||||
|
||||
const ONES: [&str; 20] = [
|
||||
"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven",
|
||||
"Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen",
|
||||
"",
|
||||
"One",
|
||||
"Two",
|
||||
"Three",
|
||||
"Four",
|
||||
"Five",
|
||||
"Six",
|
||||
"Seven",
|
||||
"Eight",
|
||||
"Nine",
|
||||
"Ten",
|
||||
"Eleven",
|
||||
"Twelve",
|
||||
"Thirteen",
|
||||
"Fourteen",
|
||||
"Fifteen",
|
||||
"Sixteen",
|
||||
"Seventeen",
|
||||
"Eighteen",
|
||||
"Nineteen",
|
||||
];
|
||||
|
||||
const TENS: [&str; 10] = [
|
||||
@@ -353,9 +375,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tax_type_serde_strings() {
|
||||
assert_eq!(serde_json::to_string(&TaxType::CgstSgst).unwrap(), "\"cgst_sgst\"");
|
||||
assert_eq!(serde_json::from_str::<TaxType>("\"igst\"").unwrap(), TaxType::Igst);
|
||||
assert_eq!(serde_json::from_str::<TaxType>("\"none\"").unwrap(), TaxType::None);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TaxType::CgstSgst).unwrap(),
|
||||
"\"cgst_sgst\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TaxType>("\"igst\"").unwrap(),
|
||||
TaxType::Igst
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TaxType>("\"none\"").unwrap(),
|
||||
TaxType::None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,17 +415,29 @@ mod tests {
|
||||
assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(()));
|
||||
// The algorithm gives W for this body, so a trailing 5 is a typo.
|
||||
assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(()));
|
||||
assert!(validate_gstin("29ABCDE1234F1Z5").unwrap_err().contains("check digit"));
|
||||
assert!(validate_gstin("27AAPFU0939F1ZX").unwrap_err().contains("check digit"));
|
||||
assert!(validate_gstin("29ABCDE1234F1Z5")
|
||||
.unwrap_err()
|
||||
.contains("check digit"));
|
||||
assert!(validate_gstin("27AAPFU0939F1ZX")
|
||||
.unwrap_err()
|
||||
.contains("check digit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gstin_rejects_bad_shapes() {
|
||||
assert!(validate_gstin("").unwrap_err().contains("15 characters"));
|
||||
assert!(validate_gstin("27AAPFU0939F1Z").unwrap_err().contains("15 characters"));
|
||||
assert!(validate_gstin("27AAPFU0939F1ZVV").unwrap_err().contains("15 characters"));
|
||||
assert!(validate_gstin("2XAAPFU0939F1ZV").unwrap_err().contains("format"));
|
||||
assert!(validate_gstin("27AAPFU0939F1AV").unwrap_err().contains("format"));
|
||||
assert!(validate_gstin("27AAPFU0939F1Z")
|
||||
.unwrap_err()
|
||||
.contains("15 characters"));
|
||||
assert!(validate_gstin("27AAPFU0939F1ZVV")
|
||||
.unwrap_err()
|
||||
.contains("15 characters"));
|
||||
assert!(validate_gstin("2XAAPFU0939F1ZV")
|
||||
.unwrap_err()
|
||||
.contains("format"));
|
||||
assert!(validate_gstin("27AAPFU0939F1AV")
|
||||
.unwrap_err()
|
||||
.contains("format"));
|
||||
// Valid shape and checksum but state 99 does not exist.
|
||||
let body = b"99AAPFU0939F1Z";
|
||||
let mut g = String::from_utf8(body.to_vec()).unwrap();
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
//! HTTP client for a Frappe/ERPNext site. All requests run here, in Rust: a webview `fetch` from
|
||||
//! `tauri://localhost` would be blocked by CORS.
|
||||
//!
|
||||
//! TLS is rustls with the `ring` provider (installed once, process-wide). Roots come from the OS through
|
||||
//! rustls-platform-verifier; an optional extra CA PEM is merged in for self-hosted sites.
|
||||
|
||||
use super::config::{ErpnextConfig, Secret};
|
||||
use super::errors::{ErpError, ErrorKind};
|
||||
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, RETRY_AFTER};
|
||||
use reqwest::{Method, Url};
|
||||
use serde_json::Value;
|
||||
use std::sync::Once;
|
||||
use std::time::Duration;
|
||||
|
||||
static CRYPTO_PROVIDER: Once = Once::new();
|
||||
|
||||
/// Installs the ring crypto provider for rustls. Idempotent; must run before the first client is built.
|
||||
pub fn ensure_crypto_provider() {
|
||||
CRYPTO_PROVIDER.call_once(|| {
|
||||
// Err means another provider is already installed, which is fine for our purposes.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientOptions {
|
||||
pub connect_timeout: Duration,
|
||||
pub read_timeout: Duration,
|
||||
pub total_timeout: Duration,
|
||||
/// Retries after the first attempt.
|
||||
pub max_retries: u32,
|
||||
pub backoff_base: Duration,
|
||||
pub max_backoff: Duration,
|
||||
}
|
||||
|
||||
impl Default for ClientOptions {
|
||||
fn default() -> Self {
|
||||
ClientOptions {
|
||||
connect_timeout: Duration::from_secs(10),
|
||||
read_timeout: Duration::from_secs(30),
|
||||
total_timeout: Duration::from_secs(60),
|
||||
max_retries: 3,
|
||||
backoff_base: Duration::from_millis(500),
|
||||
max_backoff: Duration::from_secs(8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain http is only for a local development site; anything else must use https.
|
||||
pub fn is_local_dev_host(host: &str) -> bool {
|
||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||
host == "localhost"
|
||||
|| host == "127.0.0.1"
|
||||
|| host.ends_with(".localhost")
|
||||
|| host.ends_with(".test")
|
||||
}
|
||||
|
||||
/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash.
|
||||
/// A missing scheme means https, except for local development hosts, which get http.
|
||||
pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Err(ErpError::config("The ERPNext address is empty."));
|
||||
}
|
||||
let with_scheme = if raw.contains("://") {
|
||||
raw.to_string()
|
||||
} else {
|
||||
let host = raw.split(['/', ':']).next().unwrap_or("");
|
||||
let scheme = if is_local_dev_host(host) {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
format!("{scheme}://{raw}")
|
||||
};
|
||||
let mut url = Url::parse(&with_scheme)
|
||||
.map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| ErpError::config("The ERPNext address has no host name."))?
|
||||
.to_string();
|
||||
match url.scheme() {
|
||||
"https" => {}
|
||||
"http" => {
|
||||
if !is_local_dev_host(&host) {
|
||||
return Err(ErpError::config(
|
||||
"Use an https:// address. Plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test.",
|
||||
));
|
||||
}
|
||||
}
|
||||
other => {
|
||||
return Err(ErpError::config(format!(
|
||||
"Unsupported address scheme \"{other}\"; use https://."
|
||||
)))
|
||||
}
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(ErpError::config(
|
||||
"Do not put credentials in the address; use the API key and secret fields.",
|
||||
));
|
||||
}
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// A file to upload through a multipart POST.
|
||||
pub struct Upload<'a> {
|
||||
pub file_name: &'a str,
|
||||
pub mime: &'a str,
|
||||
pub bytes: &'a [u8],
|
||||
pub fields: &'a [(&'a str, String)],
|
||||
}
|
||||
|
||||
enum Payload<'a> {
|
||||
None,
|
||||
Json(&'a Value),
|
||||
Upload(&'a Upload<'a>),
|
||||
}
|
||||
|
||||
pub struct ErpClient {
|
||||
http: reqwest::Client,
|
||||
base: Url,
|
||||
opts: ClientOptions,
|
||||
/// Strings scrubbed from every error message.
|
||||
secrets: Vec<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ErpClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ErpClient")
|
||||
.field("base", &self.base.as_str())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ErpClient {
|
||||
pub fn new(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
api_secret: &Secret,
|
||||
extra_ca_pem: &str,
|
||||
opts: ClientOptions,
|
||||
) -> Result<Self, ErpError> {
|
||||
ensure_crypto_provider();
|
||||
let base = Url::parse(&normalize_base_url(base_url)?)
|
||||
.map_err(|e| ErpError::config(e.to_string()))?;
|
||||
if api_key.trim().is_empty() || !api_secret.is_set() {
|
||||
return Err(ErpError::config("Enter the API key and API secret."));
|
||||
}
|
||||
let mut auth =
|
||||
HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose()))
|
||||
.map_err(|_| {
|
||||
ErpError::config(
|
||||
"The API key or secret contains characters that cannot be sent.",
|
||||
)
|
||||
})?;
|
||||
auth.set_sensitive(true);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, auth);
|
||||
// Frappe answers `Accept: text/*` with HTML; always ask for JSON.
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.user_agent(concat!("Voiced/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(opts.connect_timeout)
|
||||
.read_timeout(opts.read_timeout)
|
||||
.timeout(opts.total_timeout)
|
||||
// A redirect drops the body or the auth header; surface it instead of guessing.
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
if !extra_ca_pem.trim().is_empty() {
|
||||
let certs = reqwest::Certificate::from_pem_bundle(extra_ca_pem.trim().as_bytes())
|
||||
.map_err(|_| ErpError::config("The extra CA certificate is not valid PEM."))?;
|
||||
if certs.is_empty() {
|
||||
return Err(ErpError::config(
|
||||
"The extra CA certificate field holds no certificate.",
|
||||
));
|
||||
}
|
||||
builder = builder.tls_certs_merge(certs);
|
||||
}
|
||||
let http = builder.build().map_err(|e| {
|
||||
ErpError::config(format!(
|
||||
"Could not set up the HTTP client: {}",
|
||||
error_chain(&e)
|
||||
))
|
||||
})?;
|
||||
Ok(ErpClient {
|
||||
http,
|
||||
base,
|
||||
opts,
|
||||
secrets: vec![api_secret.expose().to_string(), api_key.trim().to_string()],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_config(cfg: &ErpnextConfig) -> Result<Self, ErpError> {
|
||||
Self::new(
|
||||
&cfg.base_url,
|
||||
&cfg.api_key,
|
||||
&cfg.api_secret,
|
||||
&cfg.extra_ca_pem,
|
||||
ClientOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
self.base.as_str().trim_end_matches('/')
|
||||
}
|
||||
|
||||
fn url(&self, segments: &[&str]) -> Url {
|
||||
let mut url = self.base.clone();
|
||||
if let Ok(mut path) = url.path_segments_mut() {
|
||||
path.pop_if_empty().extend(segments);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
/// GET, retried on 429, 5xx and timeouts.
|
||||
pub async fn get(
|
||||
&self,
|
||||
segments: &[&str],
|
||||
query: &[(&str, String)],
|
||||
) -> Result<Value, ErpError> {
|
||||
self.send(Method::GET, segments, query, Payload::None, true)
|
||||
.await
|
||||
}
|
||||
|
||||
/// POST with a JSON body. 429 is always retried (the server refused before doing anything). 5xx and
|
||||
/// timeouts are retried only when `idempotent` is true, because the server may have processed the
|
||||
/// request: a mirrored-name Sales Invoice is idempotent (a repeat is a 409), a series-named one is not.
|
||||
pub async fn post(
|
||||
&self,
|
||||
segments: &[&str],
|
||||
body: &Value,
|
||||
idempotent: bool,
|
||||
) -> Result<Value, ErpError> {
|
||||
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file
|
||||
/// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or
|
||||
/// timeout, because an upload that may have landed would attach the file twice.
|
||||
pub async fn post_file(
|
||||
&self,
|
||||
segments: &[&str],
|
||||
upload: &Upload<'_>,
|
||||
) -> Result<Value, ErpError> {
|
||||
self.send(Method::POST, segments, &[], Payload::Upload(upload), false)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
method: Method,
|
||||
segments: &[&str],
|
||||
query: &[(&str, String)],
|
||||
payload: Payload<'_>,
|
||||
retry_ambiguous: bool,
|
||||
) -> Result<Value, ErpError> {
|
||||
let mut attempt: u32 = 0;
|
||||
loop {
|
||||
let mut req = self.http.request(method.clone(), self.url(segments));
|
||||
if !query.is_empty() {
|
||||
req = req.query(query);
|
||||
}
|
||||
match &payload {
|
||||
Payload::None => {}
|
||||
Payload::Json(b) => req = req.json(*b),
|
||||
Payload::Upload(u) => {
|
||||
let part = reqwest::multipart::Part::bytes(u.bytes.to_vec())
|
||||
.file_name(u.file_name.to_string())
|
||||
.mime_str(u.mime)
|
||||
.map_err(|e| ErpError::config(format!("Bad upload type: {e}")))?;
|
||||
let mut form = reqwest::multipart::Form::new();
|
||||
for (k, v) in u.fields {
|
||||
form = form.text(k.to_string(), v.clone());
|
||||
}
|
||||
req = req.multipart(form.part("file", part));
|
||||
}
|
||||
}
|
||||
let can_retry = attempt < self.opts.max_retries;
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let retry_after = resp
|
||||
.headers()
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||
.map(Duration::from_secs);
|
||||
let location = resp
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let text = match resp.text().await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
if e.is_timeout() && retry_ambiguous && can_retry {
|
||||
self.backoff(attempt, None).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(self.map_transport(&e));
|
||||
}
|
||||
};
|
||||
if status.is_success() {
|
||||
return self.parse_success(&text);
|
||||
}
|
||||
let retryable =
|
||||
status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
|
||||
if retryable && can_retry {
|
||||
self.backoff(attempt, retry_after).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
if status.is_redirection() {
|
||||
let to = location.unwrap_or_else(|| "another address".to_string());
|
||||
return Err(self.scrub(ErpError::protocol(format!(
|
||||
"The server redirected the request to {to}. Use the final address (usually https://) in the ERPNext address field."
|
||||
))));
|
||||
}
|
||||
return Err(self.scrub(ErpError::from_response(status.as_u16(), &text)));
|
||||
}
|
||||
Err(e) => {
|
||||
if e.is_timeout() && retry_ambiguous && can_retry {
|
||||
self.backoff(attempt, None).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(self.map_transport(&e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) {
|
||||
let exp = self
|
||||
.opts
|
||||
.backoff_base
|
||||
.saturating_mul(1u32 << attempt.min(10));
|
||||
let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
fn parse_success(&self, text: &str) -> Result<Value, ErpError> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
serde_json::from_str(text).map_err(|_| {
|
||||
ErpError::protocol(
|
||||
"The server did not return JSON. Check the address: it should be the root of your ERPNext site.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn scrub(&self, err: ErpError) -> ErpError {
|
||||
let secrets: Vec<&str> = self.secrets.iter().map(String::as_str).collect();
|
||||
err.redacted(&secrets)
|
||||
}
|
||||
|
||||
fn map_transport(&self, e: &reqwest::Error) -> ErpError {
|
||||
let chain = error_chain(e);
|
||||
let err = if e.is_timeout() {
|
||||
ErpError::new(
|
||||
ErrorKind::Timeout,
|
||||
"The ERPNext server did not answer in time.",
|
||||
)
|
||||
} else if e.is_connect() {
|
||||
let lower = chain.to_ascii_lowercase();
|
||||
let hint = if lower.contains("certificate") || lower.contains("unknownissuer") {
|
||||
" The certificate is not trusted: paste the site's CA certificate into the extra CA field."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
ErpError::new(
|
||||
ErrorKind::Network,
|
||||
format!("Could not connect to the ERPNext server ({chain}).{hint}"),
|
||||
)
|
||||
} else {
|
||||
ErpError::new(
|
||||
ErrorKind::Network,
|
||||
format!("The request to ERPNext failed ({chain})."),
|
||||
)
|
||||
};
|
||||
self.scrub(err)
|
||||
}
|
||||
}
|
||||
|
||||
/// `a: b: c` for an error and its sources.
|
||||
fn error_chain(e: &dyn std::error::Error) -> String {
|
||||
let mut parts = vec![e.to_string()];
|
||||
let mut source = e.source();
|
||||
while let Some(s) = source {
|
||||
let text = s.to_string();
|
||||
if parts.last() != Some(&text) {
|
||||
parts.push(text);
|
||||
}
|
||||
source = s.source();
|
||||
}
|
||||
parts.join(": ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::integrations::erpnext::testutil::{client, fast_opts, reply, serve, Reply};
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
// ---- URL rules ----
|
||||
|
||||
#[test]
|
||||
fn https_is_accepted_and_normalised() {
|
||||
assert_eq!(
|
||||
normalize_base_url(" https://Erp.Example.com/ ").unwrap(),
|
||||
"https://erp.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_base_url("erp.example.com").unwrap(),
|
||||
"https://erp.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(),
|
||||
"https://erp.example.com:8443/sub"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_is_only_for_local_development_hosts() {
|
||||
for ok in [
|
||||
"http://localhost:8000",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://mysite.localhost:8000",
|
||||
"http://erp.test",
|
||||
"localhost:8000",
|
||||
"127.0.0.1:8000",
|
||||
] {
|
||||
let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}"));
|
||||
assert!(url.starts_with("http://"), "{ok} -> {url}");
|
||||
}
|
||||
for bad in [
|
||||
"http://erp.example.com",
|
||||
"http://192.168.1.10:8000",
|
||||
"http://localhost.evil.com",
|
||||
"http://notlocalhost",
|
||||
] {
|
||||
assert!(normalize_base_url(bad).is_err(), "{bad} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_schemes_credentials_and_blanks_are_refused() {
|
||||
for bad in [
|
||||
"ftp://erp.example.com",
|
||||
"file:///etc/passwd",
|
||||
"https://user:pw@erp.example.com",
|
||||
"",
|
||||
" ",
|
||||
"https://",
|
||||
] {
|
||||
assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sends_token_auth_and_a_json_accept_header() {
|
||||
let mock = serve(vec![reply(200, json!({ "message": "Sample@example.com" }))]).await;
|
||||
let value = client(&mock)
|
||||
.get(&["api", "method", "frappe.auth.get_logged_user"], &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value["message"], "Sample@example.com");
|
||||
let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase();
|
||||
assert!(
|
||||
raw.starts_with("get /api/method/frappe.auth.get_logged_user "),
|
||||
"{raw}"
|
||||
);
|
||||
assert!(
|
||||
raw.contains("authorization: token thekey:thesecret"),
|
||||
"{raw}"
|
||||
);
|
||||
assert!(raw.contains("accept: application/json"), "{raw}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_segments_are_percent_encoded_once() {
|
||||
let mock = serve(vec![reply(200, json!({ "data": [] }))]).await;
|
||||
client(&mock)
|
||||
.get(
|
||||
&["api", "resource", "Sales Invoice", "INV/2026-001"],
|
||||
&[("limit_page_length", "0".into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let raw = mock.requests.lock().unwrap()[0].clone();
|
||||
assert!(
|
||||
raw.starts_with(
|
||||
"GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "
|
||||
),
|
||||
"{raw}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_429_then_succeeds() {
|
||||
let mut limited = reply(429, json!({ "message": "slow down" }));
|
||||
limited.headers.push(("Retry-After", "0".into()));
|
||||
let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await;
|
||||
let value = client(&mock)
|
||||
.get(&["api", "method", "ping"], &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value["message"], "ok");
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistent_500_exhausts_the_retries() {
|
||||
let mock = serve(vec![reply(
|
||||
500,
|
||||
json!({ "exception": "frappe.exceptions.ValidationError: boom" }),
|
||||
)])
|
||||
.await;
|
||||
let err = client(&mock)
|
||||
.get(&["api", "method", "ping"], &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Server);
|
||||
assert_eq!(err.message, "boom");
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_errors_are_not_retried() {
|
||||
let mock = serve(vec![reply(
|
||||
417,
|
||||
json!({ "_error_message": "Rate is required" }),
|
||||
)])
|
||||
.await;
|
||||
let err = client(&mock)
|
||||
.post(
|
||||
&["api", "resource", "Sales Invoice"],
|
||||
&json!({ "a": 1 }),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Validation);
|
||||
assert_eq!(err.message, "Rate is required");
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() {
|
||||
let slow = Reply {
|
||||
delay_ms: 2_000,
|
||||
..reply(200, json!({}))
|
||||
};
|
||||
let mock = serve(vec![slow]).await;
|
||||
let err = client(&mock)
|
||||
.get(&["api", "method", "ping"], &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Timeout);
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 4);
|
||||
|
||||
let slow = Reply {
|
||||
delay_ms: 2_000,
|
||||
..reply(200, json!({}))
|
||||
};
|
||||
let mock = serve(vec![slow]).await;
|
||||
let err = client(&mock)
|
||||
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Timeout);
|
||||
assert_eq!(
|
||||
mock.hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"a write that may have landed is not repeated"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_idempotent_write_retries_429_but_not_500() {
|
||||
let mock = serve(vec![
|
||||
reply(429, json!({})),
|
||||
reply(200, json!({ "data": { "name": "X" } })),
|
||||
])
|
||||
.await;
|
||||
let value = client(&mock)
|
||||
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value["data"]["name"], "X");
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
|
||||
|
||||
let mock = serve(vec![reply(500, json!({}))]).await;
|
||||
let err = client(&mock)
|
||||
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Server);
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn redirects_are_reported_not_followed() {
|
||||
let mut moved = reply(301, json!({}));
|
||||
moved
|
||||
.headers
|
||||
.push(("Location", "https://erp.example.com/".into()));
|
||||
let mock = serve(vec![moved]).await;
|
||||
let err = client(&mock)
|
||||
.get(&["api", "method", "ping"], &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Protocol);
|
||||
assert!(
|
||||
err.message.contains("https://erp.example.com/"),
|
||||
"{}",
|
||||
err.message
|
||||
);
|
||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn html_success_page_is_a_protocol_error() {
|
||||
let mut page = reply(200, json!({}));
|
||||
page.body = "<html>login</html>".into();
|
||||
let mock = serve(vec![page]).await;
|
||||
let err = client(&mock)
|
||||
.get(&["api", "method", "ping"], &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Protocol);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refused_connection_is_a_network_error_without_the_secret() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let c = ErpClient::new(
|
||||
&format!("http://127.0.0.1:{port}"),
|
||||
"thekey",
|
||||
&Secret::new("thesecret"),
|
||||
"",
|
||||
fast_opts(),
|
||||
)
|
||||
.unwrap();
|
||||
let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Network);
|
||||
assert!(!err.to_string().contains("thesecret"));
|
||||
assert!(!format!("{c:?}").contains("thesecret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_are_required_and_a_bad_ca_is_refused() {
|
||||
assert!(ErpClient::new(
|
||||
"https://erp.example.com",
|
||||
"",
|
||||
&Secret::new("s"),
|
||||
"",
|
||||
fast_opts()
|
||||
)
|
||||
.is_err());
|
||||
assert!(ErpClient::new(
|
||||
"https://erp.example.com",
|
||||
"k",
|
||||
&Secret::default(),
|
||||
"",
|
||||
fast_opts()
|
||||
)
|
||||
.is_err());
|
||||
let err = ErpClient::new(
|
||||
"https://erp.example.com",
|
||||
"k",
|
||||
&Secret::new("s"),
|
||||
"not a certificate",
|
||||
fast_opts(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
//! ERPNext connection and mapping settings (the single `erpnext_config` row).
|
||||
//!
|
||||
//! The API secret lives in the SQLite DB and is never handed back to the webview: `ErpnextConfigView`
|
||||
//! carries only `api_secret_set`. `Secret` redacts itself in `Debug`, so a stray `{:?}` cannot leak it.
|
||||
|
||||
use super::client::normalize_base_url;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A credential that never prints itself.
|
||||
#[derive(Clone, Default, PartialEq, Eq)]
|
||||
pub struct Secret(String);
|
||||
|
||||
impl Secret {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Secret(value.into())
|
||||
}
|
||||
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn is_set(&self) -> bool {
|
||||
!self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(if self.is_set() {
|
||||
"Secret([redacted])"
|
||||
} else {
|
||||
"Secret(unset)"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// How the Sales Invoice gets its name in ERPNext.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NamingMode {
|
||||
/// The Voiced number becomes the document name (API v2, Frappe >= 15.73).
|
||||
Mirror,
|
||||
/// ERPNext assigns its own series number; the Voiced number goes into `remarks`.
|
||||
Series,
|
||||
}
|
||||
|
||||
impl NamingMode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
NamingMode::Mirror => "mirror",
|
||||
NamingMode::Series => "series",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
match s {
|
||||
"mirror" => Ok(NamingMode::Mirror),
|
||||
"series" => Ok(NamingMode::Series),
|
||||
other => Err(format!("Unknown naming mode \"{other}\"")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities,
|
||||
/// which is why hours and minutes map to their own UOMs.
|
||||
pub fn default_uom_map() -> BTreeMap<String, String> {
|
||||
[
|
||||
("second", "Second"),
|
||||
("minute", "Minute"),
|
||||
("hour", "Hour"),
|
||||
("session", "Nos"),
|
||||
("unit", "Nos"),
|
||||
]
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Everything stored for the integration, secret included. Backend-only.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ErpnextConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub api_secret: Secret,
|
||||
pub extra_ca_pem: String,
|
||||
pub company: String,
|
||||
pub company_address: String,
|
||||
pub naming_mode: NamingMode,
|
||||
pub naming_series: String,
|
||||
pub income_account: String,
|
||||
pub cost_center: String,
|
||||
pub cgst_account: String,
|
||||
pub sgst_account: String,
|
||||
pub utgst_account: String,
|
||||
pub igst_account: String,
|
||||
pub tax_template_intra: String,
|
||||
pub tax_template_inter: String,
|
||||
pub payment_bank_account: String,
|
||||
pub tds_account: String,
|
||||
pub default_item_code: String,
|
||||
pub uom_map: BTreeMap<String, String>,
|
||||
pub customer_group: String,
|
||||
pub territory: String,
|
||||
pub selling_price_list: String,
|
||||
pub submit_on_push: bool,
|
||||
pub attach_pdf: bool,
|
||||
pub auto_push_on_issue: bool,
|
||||
pub create_missing_customers: bool,
|
||||
/// JSON of the last connection test (versions, IC flag, warnings); empty if never run. Never holds secrets.
|
||||
pub last_detect_result: String,
|
||||
}
|
||||
|
||||
impl Default for ErpnextConfig {
|
||||
fn default() -> Self {
|
||||
ErpnextConfig {
|
||||
base_url: String::new(),
|
||||
api_key: String::new(),
|
||||
api_secret: Secret::default(),
|
||||
extra_ca_pem: String::new(),
|
||||
company: String::new(),
|
||||
company_address: String::new(),
|
||||
naming_mode: NamingMode::Mirror,
|
||||
naming_series: String::new(),
|
||||
income_account: String::new(),
|
||||
cost_center: String::new(),
|
||||
cgst_account: String::new(),
|
||||
sgst_account: String::new(),
|
||||
utgst_account: String::new(),
|
||||
igst_account: String::new(),
|
||||
tax_template_intra: String::new(),
|
||||
tax_template_inter: String::new(),
|
||||
payment_bank_account: String::new(),
|
||||
tds_account: String::new(),
|
||||
default_item_code: String::new(),
|
||||
uom_map: default_uom_map(),
|
||||
customer_group: String::new(),
|
||||
territory: String::new(),
|
||||
selling_price_list: String::new(),
|
||||
submit_on_push: false,
|
||||
attach_pdf: true,
|
||||
auto_push_on_issue: false,
|
||||
create_missing_customers: true,
|
||||
last_detect_result: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ErpnextConfig {
|
||||
/// ERPNext UOM for a Voiced unit; falls back to the built-in default, then to "Nos".
|
||||
pub fn uom_for(&self, unit: &str) -> String {
|
||||
if let Some(u) = self.uom_map.get(unit).filter(|u| !u.trim().is_empty()) {
|
||||
return u.trim().to_string();
|
||||
}
|
||||
default_uom_map()
|
||||
.remove(unit)
|
||||
.unwrap_or_else(|| "Nos".to_string())
|
||||
}
|
||||
|
||||
pub fn view(&self) -> ErpnextConfigView {
|
||||
ErpnextConfigView {
|
||||
base_url: self.base_url.clone(),
|
||||
api_key: self.api_key.clone(),
|
||||
api_secret_set: self.api_secret.is_set(),
|
||||
extra_ca_pem: self.extra_ca_pem.clone(),
|
||||
company: self.company.clone(),
|
||||
company_address: self.company_address.clone(),
|
||||
naming_mode: self.naming_mode,
|
||||
naming_series: self.naming_series.clone(),
|
||||
income_account: self.income_account.clone(),
|
||||
cost_center: self.cost_center.clone(),
|
||||
cgst_account: self.cgst_account.clone(),
|
||||
sgst_account: self.sgst_account.clone(),
|
||||
utgst_account: self.utgst_account.clone(),
|
||||
igst_account: self.igst_account.clone(),
|
||||
tax_template_intra: self.tax_template_intra.clone(),
|
||||
tax_template_inter: self.tax_template_inter.clone(),
|
||||
payment_bank_account: self.payment_bank_account.clone(),
|
||||
tds_account: self.tds_account.clone(),
|
||||
default_item_code: self.default_item_code.clone(),
|
||||
uom_map: self.uom_map.clone(),
|
||||
customer_group: self.customer_group.clone(),
|
||||
territory: self.territory.clone(),
|
||||
selling_price_list: self.selling_price_list.clone(),
|
||||
submit_on_push: self.submit_on_push,
|
||||
attach_pdf: self.attach_pdf,
|
||||
auto_push_on_issue: self.auto_push_on_issue,
|
||||
create_missing_customers: self.create_missing_customers,
|
||||
last_detect_result: serde_json::from_str(&self.last_detect_result)
|
||||
.unwrap_or(Value::Null),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the webview sees: the same settings minus the secret.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ErpnextConfigView {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub api_secret_set: bool,
|
||||
pub extra_ca_pem: String,
|
||||
pub company: String,
|
||||
pub company_address: String,
|
||||
pub naming_mode: NamingMode,
|
||||
pub naming_series: String,
|
||||
pub income_account: String,
|
||||
pub cost_center: String,
|
||||
pub cgst_account: String,
|
||||
pub sgst_account: String,
|
||||
pub utgst_account: String,
|
||||
pub igst_account: String,
|
||||
pub tax_template_intra: String,
|
||||
pub tax_template_inter: String,
|
||||
pub payment_bank_account: String,
|
||||
pub tds_account: String,
|
||||
pub default_item_code: String,
|
||||
pub uom_map: BTreeMap<String, String>,
|
||||
pub customer_group: String,
|
||||
pub territory: String,
|
||||
pub selling_price_list: String,
|
||||
pub submit_on_push: bool,
|
||||
pub attach_pdf: bool,
|
||||
pub auto_push_on_issue: bool,
|
||||
pub create_missing_customers: bool,
|
||||
pub last_detect_result: Value,
|
||||
}
|
||||
|
||||
/// What the webview sends to save or test. `api_secret` blank keeps the stored secret; a non-blank
|
||||
/// value replaces it; `clear_secret` removes it.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ErpnextConfigInput {
|
||||
#[serde(default)]
|
||||
pub base_url: String,
|
||||
#[serde(default)]
|
||||
pub api_key: String,
|
||||
#[serde(default)]
|
||||
pub api_secret: String,
|
||||
#[serde(default)]
|
||||
pub clear_secret: bool,
|
||||
#[serde(default)]
|
||||
pub extra_ca_pem: String,
|
||||
#[serde(default)]
|
||||
pub company: String,
|
||||
#[serde(default)]
|
||||
pub company_address: String,
|
||||
#[serde(default = "default_naming_mode")]
|
||||
pub naming_mode: String,
|
||||
#[serde(default)]
|
||||
pub naming_series: String,
|
||||
#[serde(default)]
|
||||
pub income_account: String,
|
||||
#[serde(default)]
|
||||
pub cost_center: String,
|
||||
#[serde(default)]
|
||||
pub cgst_account: String,
|
||||
#[serde(default)]
|
||||
pub sgst_account: String,
|
||||
#[serde(default)]
|
||||
pub utgst_account: String,
|
||||
#[serde(default)]
|
||||
pub igst_account: String,
|
||||
#[serde(default)]
|
||||
pub tax_template_intra: String,
|
||||
#[serde(default)]
|
||||
pub tax_template_inter: String,
|
||||
#[serde(default)]
|
||||
pub payment_bank_account: String,
|
||||
#[serde(default)]
|
||||
pub tds_account: String,
|
||||
#[serde(default)]
|
||||
pub default_item_code: String,
|
||||
#[serde(default)]
|
||||
pub uom_map: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub customer_group: String,
|
||||
#[serde(default)]
|
||||
pub territory: String,
|
||||
#[serde(default)]
|
||||
pub selling_price_list: String,
|
||||
#[serde(default)]
|
||||
pub submit_on_push: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub attach_pdf: bool,
|
||||
#[serde(default)]
|
||||
pub auto_push_on_issue: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub create_missing_customers: bool,
|
||||
/// None keeps the stored value.
|
||||
#[serde(default)]
|
||||
pub last_detect_result: Option<Value>,
|
||||
}
|
||||
|
||||
fn default_naming_mode() -> String {
|
||||
"mirror".to_string()
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn origin_of(url: &str) -> String {
|
||||
url.split('/')
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
impl ErpnextConfigInput {
|
||||
/// Merges the form values with what is stored: trims, normalises the address, and decides which
|
||||
/// secret applies. The stored secret is never sent to a different server than it was saved for.
|
||||
pub fn resolve(self, stored: &ErpnextConfig) -> Result<ErpnextConfig, String> {
|
||||
let t = |s: &str| s.trim().to_string();
|
||||
let raw_url = self.base_url.trim();
|
||||
let base_url = if raw_url.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
normalize_base_url(raw_url).map_err(|e| e.to_string())?
|
||||
};
|
||||
let typed_secret = self.api_secret.trim();
|
||||
let api_secret = if self.clear_secret {
|
||||
Secret::default()
|
||||
} else if !typed_secret.is_empty() {
|
||||
Secret::new(typed_secret)
|
||||
} else {
|
||||
if stored.api_secret.is_set()
|
||||
&& !stored.base_url.is_empty()
|
||||
&& !base_url.is_empty()
|
||||
&& origin_of(&stored.base_url) != origin_of(&base_url)
|
||||
{
|
||||
return Err("The server address changed: enter the API secret again.".into());
|
||||
}
|
||||
stored.api_secret.clone()
|
||||
};
|
||||
let mut uom_map = default_uom_map();
|
||||
for (unit, uom) in &self.uom_map {
|
||||
if !uom.trim().is_empty() {
|
||||
uom_map.insert(unit.trim().to_string(), uom.trim().to_string());
|
||||
}
|
||||
}
|
||||
let last_detect_result = match &self.last_detect_result {
|
||||
Some(Value::Null) => String::new(),
|
||||
Some(v) => v.to_string(),
|
||||
None => stored.last_detect_result.clone(),
|
||||
};
|
||||
Ok(ErpnextConfig {
|
||||
base_url,
|
||||
api_key: t(&self.api_key),
|
||||
api_secret,
|
||||
extra_ca_pem: self.extra_ca_pem.trim().to_string(),
|
||||
company: t(&self.company),
|
||||
company_address: t(&self.company_address),
|
||||
naming_mode: NamingMode::parse(self.naming_mode.trim())?,
|
||||
naming_series: t(&self.naming_series),
|
||||
income_account: t(&self.income_account),
|
||||
cost_center: t(&self.cost_center),
|
||||
cgst_account: t(&self.cgst_account),
|
||||
sgst_account: t(&self.sgst_account),
|
||||
utgst_account: t(&self.utgst_account),
|
||||
igst_account: t(&self.igst_account),
|
||||
tax_template_intra: t(&self.tax_template_intra),
|
||||
tax_template_inter: t(&self.tax_template_inter),
|
||||
payment_bank_account: t(&self.payment_bank_account),
|
||||
tds_account: t(&self.tds_account),
|
||||
default_item_code: t(&self.default_item_code),
|
||||
uom_map,
|
||||
customer_group: t(&self.customer_group),
|
||||
territory: t(&self.territory),
|
||||
selling_price_list: t(&self.selling_price_list),
|
||||
submit_on_push: self.submit_on_push,
|
||||
attach_pdf: self.attach_pdf,
|
||||
auto_push_on_issue: self.auto_push_on_issue,
|
||||
create_missing_customers: self.create_missing_customers,
|
||||
last_detect_result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str = "SELECT base_url, api_key, api_secret, extra_ca_pem, company, company_address, naming_mode,
|
||||
naming_series, income_account, cost_center, cgst_account, sgst_account, utgst_account, igst_account,
|
||||
tax_template_intra, tax_template_inter, payment_bank_account, tds_account, default_item_code, uom_map,
|
||||
customer_group, territory, selling_price_list, submit_on_push, attach_pdf, auto_push_on_issue,
|
||||
create_missing_customers, last_detect_result FROM erpnext_config WHERE id = 1";
|
||||
|
||||
pub fn load(conn: &Connection) -> Result<ErpnextConfig, String> {
|
||||
conn.query_row(SELECT, [], |r| {
|
||||
let naming: String = r.get(6)?;
|
||||
let uom_json: String = r.get(19)?;
|
||||
let mut uom_map = default_uom_map();
|
||||
if let Ok(stored) = serde_json::from_str::<BTreeMap<String, String>>(&uom_json) {
|
||||
uom_map.extend(stored.into_iter().filter(|(_, v)| !v.trim().is_empty()));
|
||||
}
|
||||
Ok(ErpnextConfig {
|
||||
base_url: r.get(0)?,
|
||||
api_key: r.get(1)?,
|
||||
api_secret: Secret::new(r.get::<_, String>(2)?),
|
||||
extra_ca_pem: r.get(3)?,
|
||||
company: r.get(4)?,
|
||||
company_address: r.get(5)?,
|
||||
naming_mode: NamingMode::parse(&naming).unwrap_or(NamingMode::Mirror),
|
||||
naming_series: r.get(7)?,
|
||||
income_account: r.get(8)?,
|
||||
cost_center: r.get(9)?,
|
||||
cgst_account: r.get(10)?,
|
||||
sgst_account: r.get(11)?,
|
||||
utgst_account: r.get(12)?,
|
||||
igst_account: r.get(13)?,
|
||||
tax_template_intra: r.get(14)?,
|
||||
tax_template_inter: r.get(15)?,
|
||||
payment_bank_account: r.get(16)?,
|
||||
tds_account: r.get(17)?,
|
||||
default_item_code: r.get(18)?,
|
||||
uom_map,
|
||||
customer_group: r.get(20)?,
|
||||
territory: r.get(21)?,
|
||||
selling_price_list: r.get(22)?,
|
||||
submit_on_push: r.get::<_, i64>(23)? != 0,
|
||||
attach_pdf: r.get::<_, i64>(24)? != 0,
|
||||
auto_push_on_issue: r.get::<_, i64>(25)? != 0,
|
||||
create_missing_customers: r.get::<_, i64>(26)? != 0,
|
||||
last_detect_result: r.get(27)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn save(conn: &Connection, c: &ErpnextConfig) -> Result<(), String> {
|
||||
let uom_json = serde_json::to_string(&c.uom_map).map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"UPDATE erpnext_config SET base_url = ?1, api_key = ?2, api_secret = ?3, extra_ca_pem = ?4,
|
||||
company = ?5, company_address = ?6, naming_mode = ?7, naming_series = ?8, income_account = ?9,
|
||||
cost_center = ?10, cgst_account = ?11, sgst_account = ?12, utgst_account = ?13, igst_account = ?14,
|
||||
tax_template_intra = ?15, tax_template_inter = ?16, payment_bank_account = ?17, tds_account = ?18,
|
||||
default_item_code = ?19, uom_map = ?20, customer_group = ?21, territory = ?22,
|
||||
selling_price_list = ?23, submit_on_push = ?24, attach_pdf = ?25, auto_push_on_issue = ?26,
|
||||
create_missing_customers = ?27, last_detect_result = ?28,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 1",
|
||||
params![
|
||||
c.base_url,
|
||||
c.api_key,
|
||||
c.api_secret.expose(),
|
||||
c.extra_ca_pem,
|
||||
c.company,
|
||||
c.company_address,
|
||||
c.naming_mode.as_str(),
|
||||
c.naming_series,
|
||||
c.income_account,
|
||||
c.cost_center,
|
||||
c.cgst_account,
|
||||
c.sgst_account,
|
||||
c.utgst_account,
|
||||
c.igst_account,
|
||||
c.tax_template_intra,
|
||||
c.tax_template_inter,
|
||||
c.payment_bank_account,
|
||||
c.tds_account,
|
||||
c.default_item_code,
|
||||
uom_json,
|
||||
c.customer_group,
|
||||
c.territory,
|
||||
c.selling_price_list,
|
||||
c.submit_on_push as i64,
|
||||
c.attach_pdf as i64,
|
||||
c.auto_push_on_issue as i64,
|
||||
c.create_missing_customers as i64,
|
||||
c.last_detect_result,
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
|
||||
let mut base =
|
||||
serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" });
|
||||
base.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(extra.as_object().unwrap().clone());
|
||||
serde_json::from_value(base).unwrap()
|
||||
}
|
||||
|
||||
fn db() -> Connection {
|
||||
crate::db::open_in_memory().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_prints_the_secret() {
|
||||
let cfg = ErpnextConfig {
|
||||
api_secret: Secret::new("s3cr3t-value"),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!format!("{cfg:?}").contains("s3cr3t-value"));
|
||||
assert!(!format!("{:?}", Secret::new("s3cr3t-value")).contains("s3cr3t-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_load_from_the_migration_row() {
|
||||
let cfg = load(&db()).unwrap();
|
||||
assert_eq!(cfg.naming_mode, NamingMode::Mirror);
|
||||
assert!(
|
||||
!cfg.submit_on_push
|
||||
&& cfg.attach_pdf
|
||||
&& !cfg.auto_push_on_issue
|
||||
&& cfg.create_missing_customers
|
||||
);
|
||||
assert_eq!(cfg.uom_for("hour"), "Hour");
|
||||
assert_eq!(cfg.uom_for("session"), "Nos");
|
||||
assert!(!cfg.api_secret.is_set());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_keeps_secret_server_side_only() {
|
||||
let conn = db();
|
||||
let stored = load(&conn).unwrap();
|
||||
let cfg = input(serde_json::json!({ "apiSecret": "topsecret", "company": "Test Co", "submitOnPush": true }))
|
||||
.resolve(&stored)
|
||||
.unwrap();
|
||||
save(&conn, &cfg).unwrap();
|
||||
|
||||
let back = load(&conn).unwrap();
|
||||
assert_eq!(back.api_secret.expose(), "topsecret");
|
||||
assert_eq!(back.base_url, "https://erp.example.com");
|
||||
assert_eq!(back.company, "Test Co");
|
||||
assert!(back.submit_on_push);
|
||||
|
||||
let view = serde_json::to_value(back.view()).unwrap();
|
||||
assert_eq!(view["apiSecretSet"], true);
|
||||
assert!(!view.to_string().contains("topsecret"));
|
||||
assert!(view.get("apiSecret").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_secret_keeps_and_explicit_values_replace_or_clear() {
|
||||
let conn = db();
|
||||
let first = input(serde_json::json!({ "apiSecret": "one" }))
|
||||
.resolve(&load(&conn).unwrap())
|
||||
.unwrap();
|
||||
save(&conn, &first).unwrap();
|
||||
|
||||
let keep = input(serde_json::json!({ "apiSecret": " " }))
|
||||
.resolve(&load(&conn).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(keep.api_secret.expose(), "one");
|
||||
let replace = input(serde_json::json!({ "apiSecret": "two" }))
|
||||
.resolve(&load(&conn).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(replace.api_secret.expose(), "two");
|
||||
let clear = input(serde_json::json!({ "clearSecret": true }))
|
||||
.resolve(&load(&conn).unwrap())
|
||||
.unwrap();
|
||||
assert!(!clear.api_secret.is_set());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_secret_is_not_reused_for_another_server() {
|
||||
let conn = db();
|
||||
let first = input(serde_json::json!({ "apiSecret": "one" }))
|
||||
.resolve(&load(&conn).unwrap())
|
||||
.unwrap();
|
||||
save(&conn, &first).unwrap();
|
||||
let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" }))
|
||||
.resolve(&load(&conn).unwrap());
|
||||
assert!(moved.unwrap_err().contains("API secret"));
|
||||
// The same server with a different path or case is still the same origin.
|
||||
let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" }))
|
||||
.resolve(&load(&conn).unwrap());
|
||||
assert!(same.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_address_or_naming_mode_is_rejected() {
|
||||
let stored = ErpnextConfig::default();
|
||||
assert!(
|
||||
input(serde_json::json!({ "baseUrl": "http://erp.example.com" }))
|
||||
.resolve(&stored)
|
||||
.is_err()
|
||||
);
|
||||
assert!(input(serde_json::json!({ "namingMode": "random" }))
|
||||
.resolve(&stored)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uom_map_merges_over_defaults() {
|
||||
let cfg = input(serde_json::json!({ "uomMap": { "hour": "Hr", "unit": "" } }))
|
||||
.resolve(&ErpnextConfig::default())
|
||||
.unwrap();
|
||||
assert_eq!(cfg.uom_for("hour"), "Hr");
|
||||
assert_eq!(cfg.uom_for("unit"), "Nos");
|
||||
assert_eq!(cfg.uom_for("minute"), "Minute");
|
||||
assert_eq!(cfg.uom_for("unheard-of"), "Nos");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
//! Turning Frappe/ERPNext error responses into one readable message plus a machine-usable kind.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorKind {
|
||||
/// 401: wrong key/secret.
|
||||
Auth,
|
||||
/// 403: the user lacks a role or permission.
|
||||
Permission,
|
||||
NotFound,
|
||||
/// 409 (or a `DuplicateEntryError`): the document already exists.
|
||||
Duplicate,
|
||||
/// 417 and other 4xx data rejections.
|
||||
Validation,
|
||||
RateLimit,
|
||||
Server,
|
||||
Timeout,
|
||||
Network,
|
||||
/// A setting is missing or invalid; nothing was sent.
|
||||
Config,
|
||||
/// The server answered, but not like a Frappe site.
|
||||
Protocol,
|
||||
/// The remote document exists with different data; Voiced never overwrites it.
|
||||
Conflict,
|
||||
/// Voiced refused before sending anything (a draft or cancelled invoice, an unsubmitted parent, bad local data).
|
||||
Precondition,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ErrorKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
ErrorKind::Auth => "Authentication failed",
|
||||
ErrorKind::Permission => "Permission denied",
|
||||
ErrorKind::NotFound => "Not found",
|
||||
ErrorKind::Duplicate => "Already exists",
|
||||
ErrorKind::Validation => "ERPNext rejected the data",
|
||||
ErrorKind::RateLimit => "Rate limited",
|
||||
ErrorKind::Server => "ERPNext server error",
|
||||
ErrorKind::Timeout
|
||||
| ErrorKind::Network
|
||||
| ErrorKind::Config
|
||||
| ErrorKind::Protocol
|
||||
| ErrorKind::Conflict
|
||||
| ErrorKind::Precondition => "",
|
||||
ErrorKind::Other => "Request failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ErpError {
|
||||
pub kind: ErrorKind,
|
||||
pub status: Option<u16>,
|
||||
pub message: String,
|
||||
/// Frappe exception class (e.g. `DuplicateEntryError`) when the server named one.
|
||||
pub exc_type: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ErpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let label = self.kind.label();
|
||||
match (label.is_empty(), self.status) {
|
||||
(true, _) => f.write_str(&self.message),
|
||||
(false, Some(status)) => write!(f, "{label} (HTTP {status}): {}", self.message),
|
||||
(false, None) => write!(f, "{label}: {}", self.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ErpError {}
|
||||
|
||||
impl From<ErpError> for String {
|
||||
fn from(e: ErpError) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl ErpError {
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
ErpError {
|
||||
kind,
|
||||
status: None,
|
||||
message: message.into(),
|
||||
exc_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Config, message)
|
||||
}
|
||||
|
||||
pub fn protocol(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Protocol, message)
|
||||
}
|
||||
|
||||
/// Builds the error for a non-2xx response.
|
||||
pub fn from_response(status: u16, body: &str) -> Self {
|
||||
let parsed = parse_error_body(body);
|
||||
let mut kind = kind_for_status(status);
|
||||
if parsed.exc_type.as_deref() == Some("DuplicateEntryError") {
|
||||
kind = ErrorKind::Duplicate;
|
||||
}
|
||||
let message = parsed
|
||||
.message
|
||||
.unwrap_or_else(|| default_status_text(status));
|
||||
ErpError {
|
||||
kind,
|
||||
status: Some(status),
|
||||
message,
|
||||
exc_type: parsed.exc_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes every occurrence of the given secrets from the message.
|
||||
pub fn redacted(mut self, secrets: &[&str]) -> Self {
|
||||
for s in secrets.iter().filter(|s| s.len() >= 4) {
|
||||
self.message = self.message.replace(s, "[redacted]");
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind_for_status(status: u16) -> ErrorKind {
|
||||
match status {
|
||||
401 => ErrorKind::Auth,
|
||||
403 => ErrorKind::Permission,
|
||||
404 => ErrorKind::NotFound,
|
||||
409 => ErrorKind::Duplicate,
|
||||
429 => ErrorKind::RateLimit,
|
||||
400 | 417 | 422 => ErrorKind::Validation,
|
||||
500..=599 => ErrorKind::Server,
|
||||
_ => ErrorKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_status_text(status: u16) -> String {
|
||||
let reason = reqwest::StatusCode::from_u16(status)
|
||||
.ok()
|
||||
.and_then(|s| s.canonical_reason())
|
||||
.unwrap_or("unexpected response");
|
||||
format!("HTTP {status} {reason}")
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct ParsedError {
|
||||
pub message: Option<String>,
|
||||
pub exc_type: Option<String>,
|
||||
}
|
||||
|
||||
const MAX_MESSAGE_CHARS: usize = 600;
|
||||
|
||||
/// Extracts the human message from a Frappe error body.
|
||||
///
|
||||
/// v2 puts `errors[]` first. v1 uses `_server_messages` (a JSON string holding a list of JSON strings,
|
||||
/// each usually an object with an HTML `message`), then `_error_message`, then `exception` (with the
|
||||
/// `frappe.exceptions.X: ` prefix dropped), then `exc_type`. A body that is not JSON (an nginx 502 page)
|
||||
/// yields its stripped text, or nothing when that is empty.
|
||||
pub fn parse_error_body(body: &str) -> ParsedError {
|
||||
let Ok(json) = serde_json::from_str::<Value>(body) else {
|
||||
let text = strip_html(body);
|
||||
let first = text
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
return ParsedError {
|
||||
message: non_empty(truncate(first)),
|
||||
exc_type: None,
|
||||
};
|
||||
};
|
||||
let Some(obj) = json.as_object() else {
|
||||
return ParsedError::default();
|
||||
};
|
||||
|
||||
let mut exc_type = obj
|
||||
.get("exc_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let mut messages: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(errors) = obj.get("errors").and_then(Value::as_array) {
|
||||
for e in errors {
|
||||
match e {
|
||||
Value::String(s) => push_unique(&mut messages, strip_html(s)),
|
||||
Value::Object(o) => {
|
||||
let text = o
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.map(strip_html)
|
||||
.filter(|m| !m.is_empty());
|
||||
let exception = o
|
||||
.get("exception")
|
||||
.and_then(Value::as_str)
|
||||
.map(split_exception);
|
||||
if exc_type.is_none() {
|
||||
exc_type = o
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or_else(|| exception.as_ref().and_then(|(t, _)| t.clone()));
|
||||
}
|
||||
if let Some(m) =
|
||||
text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty()))
|
||||
{
|
||||
push_unique(&mut messages, m);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
if let Some(raw) = obj.get("_server_messages").and_then(Value::as_str) {
|
||||
for m in server_messages(raw) {
|
||||
push_unique(&mut messages, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
if messages.is_empty() {
|
||||
if let Some(m) = obj
|
||||
.get("_error_message")
|
||||
.and_then(Value::as_str)
|
||||
.map(strip_html)
|
||||
.filter(|m| !m.is_empty())
|
||||
{
|
||||
messages.push(m);
|
||||
}
|
||||
}
|
||||
if messages.is_empty() {
|
||||
if let Some(raw) = obj.get("exception").and_then(Value::as_str) {
|
||||
let (ty, msg) = split_exception(raw);
|
||||
if exc_type.is_none() {
|
||||
exc_type = ty;
|
||||
}
|
||||
if !msg.is_empty() {
|
||||
messages.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
if messages.is_empty() {
|
||||
if let Some(m) = obj
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.map(strip_html)
|
||||
.filter(|m| !m.is_empty())
|
||||
{
|
||||
messages.push(m);
|
||||
}
|
||||
}
|
||||
if messages.is_empty() {
|
||||
if let Some(t) = &exc_type {
|
||||
messages.push(t.clone());
|
||||
}
|
||||
}
|
||||
if exc_type.is_none() {
|
||||
// v1 on Frappe 14 has no `exc_type`; the class is still named in `exception`.
|
||||
exc_type = obj
|
||||
.get("exception")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|e| split_exception(e).0);
|
||||
}
|
||||
|
||||
ParsedError {
|
||||
message: non_empty(truncate(&messages.join("; "))),
|
||||
exc_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn server_messages(raw: &str) -> Vec<String> {
|
||||
let Ok(Value::Array(items)) = serde_json::from_str::<Value>(raw) else {
|
||||
return Vec::new();
|
||||
};
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let text = match item {
|
||||
Value::String(s) => match serde_json::from_str::<Value>(s) {
|
||||
Ok(Value::Object(o)) => o
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
_ => s.clone(),
|
||||
},
|
||||
Value::Object(o) => o
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let text = strip_html(&text);
|
||||
(!text.is_empty()).then_some(text)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Splits `frappe.exceptions.ValidationError: Row 1: bad` into (`ValidationError`, `Row 1: bad`).
|
||||
/// A string without a dotted-identifier prefix is returned whole.
|
||||
fn split_exception(raw: &str) -> (Option<String>, String) {
|
||||
let first_line = raw.trim().lines().next().unwrap_or("").trim();
|
||||
if let Some((prefix, rest)) = first_line.split_once(": ") {
|
||||
let class_like =
|
||||
prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception");
|
||||
if class_like
|
||||
&& prefix
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||
{
|
||||
let ty = prefix.rsplit('.').next().map(str::to_string);
|
||||
return (ty, strip_html(rest));
|
||||
}
|
||||
}
|
||||
if !first_line.is_empty()
|
||||
&& first_line
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||
&& first_line.contains('.')
|
||||
{
|
||||
// `frappe.exceptions.DoesNotExistError` with no message.
|
||||
return (
|
||||
first_line.rsplit('.').next().map(str::to_string),
|
||||
String::new(),
|
||||
);
|
||||
}
|
||||
(None, strip_html(first_line))
|
||||
}
|
||||
|
||||
fn push_unique(list: &mut Vec<String>, text: String) {
|
||||
if !text.is_empty() && !list.contains(&text) {
|
||||
list.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty(s: String) -> Option<String> {
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
fn truncate(s: &str) -> String {
|
||||
if s.chars().count() <= MAX_MESSAGE_CHARS {
|
||||
return s.to_string();
|
||||
}
|
||||
let cut: String = s.chars().take(MAX_MESSAGE_CHARS).collect();
|
||||
format!("{cut}...")
|
||||
}
|
||||
|
||||
/// Drops tags, decodes the common entities and collapses whitespace.
|
||||
pub fn strip_html(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut in_tag = false;
|
||||
let mut tag = String::new();
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
tag.clear();
|
||||
}
|
||||
'>' if in_tag => {
|
||||
in_tag = false;
|
||||
let name = tag
|
||||
.trim_start_matches('/')
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3"
|
||||
) {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
_ if in_tag => tag.push(ch),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
let decoded = out
|
||||
.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&");
|
||||
decoded
|
||||
.lines()
|
||||
.map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn v1_server_messages_are_double_encoded_and_html_is_stripped() {
|
||||
let inner1 = json!({ "message": "<b>Row 1</b>: Account <i>Sales</i> & more", "indicator": "red" }).to_string();
|
||||
let inner2 = json!({ "message": "Second <br> problem" }).to_string();
|
||||
let server_messages = serde_json::to_string(&vec![inner1, inner2]).unwrap();
|
||||
let body = json!({
|
||||
"exc_type": "ValidationError",
|
||||
"exception": "frappe.exceptions.ValidationError: ignored because messages exist",
|
||||
"_server_messages": server_messages,
|
||||
})
|
||||
.to_string();
|
||||
let parsed = parse_error_body(&body);
|
||||
assert_eq!(
|
||||
parsed.message.as_deref(),
|
||||
Some("Row 1: Account Sales & more; Second\nproblem")
|
||||
);
|
||||
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
|
||||
let err = ErpError::from_response(417, &body);
|
||||
assert_eq!(err.kind, ErrorKind::Validation);
|
||||
assert_eq!(err.status, Some(417));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_falls_back_to_error_message_then_exception_then_exc_type() {
|
||||
let a = parse_error_body(&json!({ "_error_message": "Not <b>permitted</b>" }).to_string());
|
||||
assert_eq!(a.message.as_deref(), Some("Not permitted"));
|
||||
|
||||
let b = parse_error_body(
|
||||
&json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(),
|
||||
);
|
||||
assert_eq!(
|
||||
b.message.as_deref(),
|
||||
Some("Sales Invoice INV/1 already exists")
|
||||
);
|
||||
assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError"));
|
||||
|
||||
let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string());
|
||||
assert_eq!(c.message.as_deref(), Some("PermissionError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exception_prefix_is_only_dropped_when_it_looks_like_a_class_path() {
|
||||
let parsed = parse_error_body(
|
||||
&json!({ "exception": "Something: with a colon but no class" }).to_string(),
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.message.as_deref(),
|
||||
Some("Something: with a colon but no class")
|
||||
);
|
||||
assert_eq!(parsed.exc_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_errors_array_is_joined() {
|
||||
let body = json!({
|
||||
"errors": [
|
||||
{ "type": "ValidationError", "message": "Customer <b>X</b> is disabled", "exception": "frappe.exceptions.ValidationError: x" },
|
||||
{ "type": "ValidationError", "exception": "frappe.exceptions.ValidationError: Row 2: rate missing" },
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let parsed = parse_error_body(&body);
|
||||
assert_eq!(
|
||||
parsed.message.as_deref(),
|
||||
Some("Customer X is disabled; Row 2: rate missing")
|
||||
);
|
||||
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_json_bodies_use_stripped_text_or_status() {
|
||||
let html = "<html><body><h1>502 Bad Gateway</h1><hr>nginx</body></html>";
|
||||
let err = ErpError::from_response(502, html);
|
||||
assert_eq!(err.kind, ErrorKind::Server);
|
||||
assert_eq!(err.message, "502 Bad Gateway");
|
||||
|
||||
let empty = ErpError::from_response(503, "");
|
||||
assert_eq!(empty.message, "HTTP 503 Service Unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statuses_map_to_kinds() {
|
||||
for (status, kind) in [
|
||||
(401, ErrorKind::Auth),
|
||||
(403, ErrorKind::Permission),
|
||||
(404, ErrorKind::NotFound),
|
||||
(409, ErrorKind::Duplicate),
|
||||
(417, ErrorKind::Validation),
|
||||
(429, ErrorKind::RateLimit),
|
||||
(500, ErrorKind::Server),
|
||||
(502, ErrorKind::Server),
|
||||
(418, ErrorKind::Other),
|
||||
] {
|
||||
assert_eq!(
|
||||
ErpError::from_response(status, "").kind,
|
||||
kind,
|
||||
"status {status}"
|
||||
);
|
||||
}
|
||||
// The exception class wins when the status is generic.
|
||||
let dup = ErpError::from_response(
|
||||
417,
|
||||
&json!({ "exc_type": "DuplicateEntryError" }).to_string(),
|
||||
);
|
||||
assert_eq!(dup.kind, ErrorKind::Duplicate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_redacted_from_messages() {
|
||||
let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header")
|
||||
.redacted(&["topsecret", "abc123"]);
|
||||
assert_eq!(err.message, "bad token [redacted]:[redacted] in header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_carries_the_status_and_hint() {
|
||||
let err = ErpError::from_response(
|
||||
401,
|
||||
&json!({ "message": "Invalid credentials" }).to_string(),
|
||||
);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Authentication failed (HTTP 401): Invalid credentials"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! ERPNext / Frappe integration. All HTTP runs in Rust (see `client`); `mapping` is pure and
|
||||
//! snapshot-tested; `config` owns the stored settings and the API secret.
|
||||
|
||||
// Parts of this module are only used by the later UI step.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod discovery;
|
||||
pub mod errors;
|
||||
pub mod mapping;
|
||||
pub mod push;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testutil;
|
||||
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
@@ -0,0 +1,140 @@
|
||||
//! A tiny HTTP/1.1 mock server for client tests (one request per connection, scripted or routed replies).
|
||||
|
||||
use super::client::{ClientOptions, ErpClient};
|
||||
use super::config::Secret;
|
||||
use serde_json::Value;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
pub struct Reply {
|
||||
pub status: u16,
|
||||
pub headers: Vec<(&'static str, String)>,
|
||||
pub body: String,
|
||||
/// Pause before answering, to trigger the read timeout.
|
||||
pub delay_ms: u64,
|
||||
}
|
||||
|
||||
pub fn reply(status: u16, body: Value) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
headers: vec![],
|
||||
body: body.to_string(),
|
||||
delay_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Mock {
|
||||
pub base: String,
|
||||
pub hits: Arc<AtomicUsize>,
|
||||
/// Raw requests (head and body) in arrival order.
|
||||
pub requests: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
/// Serves the scripted replies in order; the last one repeats.
|
||||
pub async fn serve(script: Vec<Reply>) -> Mock {
|
||||
let script = Arc::new(script);
|
||||
serve_fn(move |i, _| {
|
||||
let r = &script[i.min(script.len() - 1)];
|
||||
Reply {
|
||||
status: r.status,
|
||||
headers: r.headers.clone(),
|
||||
body: r.body.clone(),
|
||||
delay_ms: r.delay_ms,
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Answers every request with `handler(request_index, request_line)`.
|
||||
pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'static) -> Mock {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let (h, r) = (hits.clone(), requests.clone());
|
||||
let handler = Arc::new(handler);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let (h, r, handler) = (h.clone(), r.clone(), handler.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 2048];
|
||||
loop {
|
||||
let n = sock.read(&mut chunk).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let head = String::from_utf8_lossy(&buf[..end]).to_string();
|
||||
let want = head
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.to_ascii_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if buf.len() >= end + 4 + want {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&buf).to_string();
|
||||
let request_line = raw.lines().next().unwrap_or("").to_string();
|
||||
r.lock().unwrap().push(raw);
|
||||
let i = h.fetch_add(1, Ordering::SeqCst);
|
||||
let rep = handler(i, &request_line);
|
||||
if rep.delay_ms > 0 {
|
||||
tokio::time::sleep(Duration::from_millis(rep.delay_ms)).await;
|
||||
}
|
||||
let mut out = format!(
|
||||
"HTTP/1.1 {} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n",
|
||||
rep.status,
|
||||
rep.body.len()
|
||||
);
|
||||
for (k, v) in &rep.headers {
|
||||
out.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
out.push_str(&rep.body);
|
||||
let _ = sock.write_all(out.as_bytes()).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
Mock {
|
||||
base: format!("http://127.0.0.1:{port}"),
|
||||
hits,
|
||||
requests,
|
||||
}
|
||||
}
|
||||
|
||||
/// Short timeouts and near-zero backoff so retry tests run in milliseconds.
|
||||
pub fn fast_opts() -> ClientOptions {
|
||||
ClientOptions {
|
||||
connect_timeout: Duration::from_secs(2),
|
||||
read_timeout: Duration::from_millis(250),
|
||||
total_timeout: Duration::from_secs(5),
|
||||
max_retries: 3,
|
||||
backoff_base: Duration::from_millis(1),
|
||||
max_backoff: Duration::from_millis(5),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client(mock: &Mock) -> ErpClient {
|
||||
ErpClient::new(
|
||||
&mock.base,
|
||||
"thekey",
|
||||
&Secret::new("thesecret"),
|
||||
"",
|
||||
fast_opts(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Outbound integrations. `InvoiceSink` is the seam between Voiced and a system that receives issued
|
||||
//! invoices (ERPNext now; a webhook, Zoho or a Tally import later). Everything target-specific lives in a
|
||||
//! submodule; the rest of the app only talks to this trait.
|
||||
|
||||
// Parts of the sink API are only used by the later UI step.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod erpnext;
|
||||
|
||||
use serde::Serialize;
|
||||
use std::future::Future;
|
||||
|
||||
/// What a sink reports after accepting an invoice.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PushedInvoice {
|
||||
/// The document name on the remote system.
|
||||
pub remote_name: String,
|
||||
/// 0 draft, 1 submitted.
|
||||
pub remote_docstatus: i64,
|
||||
/// False when the remote already had this invoice (an idempotent re-push).
|
||||
pub created: bool,
|
||||
/// Non-fatal notes, e.g. "pushed without the PDF because the invoice is not archived".
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct PushRequest {
|
||||
pub invoice_id: i64,
|
||||
/// Submit on the remote system instead of leaving a draft for review; `None` follows the sink's setting.
|
||||
pub submit: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct PaymentRequest {
|
||||
pub payment_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PushedPayment {
|
||||
pub remote_name: String,
|
||||
/// False when the payment had already been sent (an idempotent re-push).
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// The locally recorded sync state of one invoice. `status` is `none` until something was pushed, then
|
||||
/// `synced`, `error` or `conflict`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncStatus {
|
||||
pub invoice_id: i64,
|
||||
pub status: String,
|
||||
pub remote_name: String,
|
||||
/// 0 draft, 1 submitted.
|
||||
pub remote_docstatus: i64,
|
||||
pub last_error: String,
|
||||
pub synced_at: Option<String>,
|
||||
/// The archived PDF was uploaded to the remote document.
|
||||
pub attached: bool,
|
||||
}
|
||||
|
||||
impl SyncStatus {
|
||||
pub fn none(invoice_id: i64) -> Self {
|
||||
SyncStatus {
|
||||
invoice_id,
|
||||
status: "none".into(),
|
||||
remote_name: String::new(),
|
||||
remote_docstatus: 0,
|
||||
last_error: String::new(),
|
||||
synced_at: None,
|
||||
attached: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait InvoiceSink: Send + Sync {
|
||||
/// Stable identifier used in the sync table (`erpnext`, ...).
|
||||
fn id(&self) -> &'static str;
|
||||
|
||||
fn push_invoice<'a>(
|
||||
&'a self,
|
||||
request: PushRequest,
|
||||
) -> impl Future<Output = Result<PushedInvoice, String>> + Send + 'a;
|
||||
|
||||
fn push_payment<'a>(
|
||||
&'a self,
|
||||
request: PaymentRequest,
|
||||
) -> impl Future<Output = Result<PushedPayment, String>> + Send + 'a;
|
||||
|
||||
/// The locally recorded state; never touches the network.
|
||||
fn status(&self, invoice_id: i64) -> Result<SyncStatus, String>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod commands;
|
||||
mod db;
|
||||
mod gst;
|
||||
mod integrations;
|
||||
mod logo;
|
||||
mod models;
|
||||
mod selftest;
|
||||
@@ -37,40 +38,79 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
||||
let data_dir = match app.path().app_data_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
show_startup_error(app, &format!("Could not resolve the application data directory: {e}"), None);
|
||||
show_startup_error(
|
||||
app,
|
||||
&format!("Could not resolve the application data directory: {e}"),
|
||||
None,
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
||||
show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir));
|
||||
show_startup_error(
|
||||
app,
|
||||
&format!("Could not create the application data directory: {e}"),
|
||||
Some(&data_dir),
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
let local_data_dir = match app.path().app_local_data_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
show_startup_error(app, &format!("Could not resolve the local data directory: {e}"), Some(&data_dir));
|
||||
show_startup_error(
|
||||
app,
|
||||
&format!("Could not resolve the local data directory: {e}"),
|
||||
Some(&data_dir),
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::fs::create_dir_all(&local_data_dir) {
|
||||
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
||||
show_startup_error(
|
||||
app,
|
||||
&format!("Could not create the local data directory: {e}"),
|
||||
Some(&local_data_dir),
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
// A restore staged by the Data tab is swapped in now, before anything opens the database.
|
||||
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
|
||||
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
||||
commands::backup::ApplyOutcome::NothingPending => {}
|
||||
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
||||
eprintln!(
|
||||
"Backup restored; the previous data is in {}",
|
||||
safety_dir.display()
|
||||
);
|
||||
}
|
||||
commands::backup::ApplyOutcome::Failed { message } => {
|
||||
eprintln!("Backup restore failed: {message}")
|
||||
}
|
||||
}
|
||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
show_startup_error(app, &format!("Could not open the database: {e}"), Some(&data_dir));
|
||||
show_startup_error(
|
||||
app,
|
||||
&format!("Could not open the database: {e}"),
|
||||
Some(&data_dir),
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) {
|
||||
show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir));
|
||||
show_startup_error(
|
||||
app,
|
||||
&format!("Could not update stored image paths: {e}"),
|
||||
Some(&data_dir),
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
// Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries.
|
||||
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
|
||||
eprintln!("Could not derive the logo images: {e}");
|
||||
}
|
||||
commands::backup::spawn_auto_backup(dirs);
|
||||
Ok(AppState {
|
||||
db: Mutex::new(conn),
|
||||
data_dir,
|
||||
@@ -80,6 +120,9 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// After "Restart" in the Data tab the new process waits for the old one (and its single-instance lock) to go.
|
||||
commands::backup::wait_if_relaunched();
|
||||
|
||||
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
||||
// combinations. Respect an explicit user setting, otherwise turn it off.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -121,6 +164,9 @@ pub fn run() {
|
||||
commands::clients::list_clients,
|
||||
commands::clients::save_client,
|
||||
commands::clients::delete_client,
|
||||
commands::presets::list_item_presets,
|
||||
commands::presets::save_item_preset,
|
||||
commands::presets::delete_item_preset,
|
||||
commands::invoice::peek_next_invoice_number,
|
||||
commands::invoice::issue_invoice,
|
||||
commands::invoice::cancel_invoice,
|
||||
@@ -130,6 +176,16 @@ pub fn run() {
|
||||
commands::invoice::list_drafts,
|
||||
commands::invoice::get_draft,
|
||||
commands::invoice::delete_draft,
|
||||
commands::payments::record_payment,
|
||||
commands::payments::list_payments,
|
||||
commands::payments::delete_payment,
|
||||
commands::backup::create_backup,
|
||||
commands::backup::restore_backup,
|
||||
commands::backup::cancel_pending_restore,
|
||||
commands::backup::get_backup_status,
|
||||
commands::backup::set_auto_backup,
|
||||
commands::backup::restart_app,
|
||||
commands::invoice::list_invoice_ledger,
|
||||
commands::assets::import_asset,
|
||||
commands::assets::save_asset_bytes,
|
||||
commands::assets::read_asset_data_uri,
|
||||
@@ -150,6 +206,16 @@ pub fn run() {
|
||||
commands::files::get_last_export_dir,
|
||||
commands::files::reveal_in_folder,
|
||||
commands::files::open_file,
|
||||
commands::erpnext::erpnext_get_config,
|
||||
commands::erpnext::erpnext_save_config,
|
||||
commands::erpnext::erpnext_test_connection,
|
||||
commands::erpnext::erpnext_load_options,
|
||||
commands::erpnext::erpnext_push_invoice,
|
||||
commands::erpnext::erpnext_push_invoices,
|
||||
commands::erpnext::erpnext_push_payment,
|
||||
commands::erpnext::erpnext_sync_status,
|
||||
commands::erpnext::erpnext_sync_statuses,
|
||||
commands::erpnext::erpnext_open_url,
|
||||
selftest::selftest_config,
|
||||
selftest::selftest_report,
|
||||
])
|
||||
|
||||
@@ -213,7 +213,7 @@ fn clean_alpha(img: &mut RgbaImage) {
|
||||
}
|
||||
|
||||
/// Keep 64 levels per channel (steps of about 4/255, invisible in print). Anti-aliasing and encoder
|
||||
/// noise of +-1 otherwise makes flat colours incompressible: the Test Vendor logo drops from 149 KB to 75 KB.
|
||||
/// noise of +-1 otherwise makes flat colours incompressible: a sample wordmark logo drops from 149 KB to 75 KB.
|
||||
fn quantize_colour(img: &mut RgbaImage) {
|
||||
for p in img.pixels_mut() {
|
||||
for c in 0..3 {
|
||||
@@ -224,7 +224,8 @@ fn quantize_colour(img: &mut RgbaImage) {
|
||||
|
||||
fn encode_png(img: &DynamicImage) -> Result<Vec<u8>, LogoError> {
|
||||
let mut buf = Vec::new();
|
||||
let encoder = PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive);
|
||||
let encoder =
|
||||
PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive);
|
||||
img.write_with_encoder(encoder)
|
||||
.map_err(|e| LogoError::Encode(e.to_string()))?;
|
||||
Ok(buf)
|
||||
@@ -360,7 +361,9 @@ mod tests {
|
||||
}
|
||||
|
||||
fn decode_png(bytes: &[u8]) -> RgbaImage {
|
||||
image::load_from_memory_with_format(bytes, ImageFormat::Png).unwrap().to_rgba8()
|
||||
image::load_from_memory_with_format(bytes, ImageFormat::Png)
|
||||
.unwrap()
|
||||
.to_rgba8()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -427,7 +430,8 @@ mod tests {
|
||||
assert!((38..=42).contains(&m.height), "{}", m.height);
|
||||
assert!(out.knockout_png.is_none());
|
||||
// The input pixels are not edited: the printed corners are still the logo colour, not transparent.
|
||||
let printed = image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap();
|
||||
let printed =
|
||||
image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap();
|
||||
assert!(!printed.color().has_alpha());
|
||||
}
|
||||
|
||||
@@ -459,7 +463,10 @@ mod tests {
|
||||
// Right magic, broken body.
|
||||
let mut truncated = png_bytes(&alpha_logo(20, 20, (2, 2, 5, 5), [0; 3]));
|
||||
truncated.truncate(30);
|
||||
assert!(matches!(process_logo(&truncated), Err(LogoError::Decode(_))));
|
||||
assert!(matches!(
|
||||
process_logo(&truncated),
|
||||
Err(LogoError::Decode(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -470,14 +477,23 @@ mod tests {
|
||||
|
||||
// A blank image 8193 px wide compresses to a few KB, so it passes the byte cap.
|
||||
let wide = RgbaImage::new(8193, 2);
|
||||
assert!(matches!(process_logo(&png_bytes(&wide)), Err(LogoError::Dimensions)));
|
||||
assert!(matches!(
|
||||
process_logo(&png_bytes(&wide)),
|
||||
Err(LogoError::Dimensions)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_images_have_no_content() {
|
||||
assert!(matches!(process_logo(&png_bytes(&RgbaImage::new(40, 40))), Err(LogoError::Empty)));
|
||||
assert!(matches!(
|
||||
process_logo(&png_bytes(&RgbaImage::new(40, 40))),
|
||||
Err(LogoError::Empty)
|
||||
));
|
||||
let white = RgbaImage::from_pixel(40, 40, Rgba([255, 255, 255, 255]));
|
||||
assert!(matches!(process_logo(&png_bytes(&white)), Err(LogoError::Empty)));
|
||||
assert!(matches!(
|
||||
process_logo(&png_bytes(&white)),
|
||||
Err(LogoError::Empty)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -508,9 +524,15 @@ mod tests {
|
||||
assert!((m.ink_density - 0.52).abs() < 1e-9, "{}", m.ink_density);
|
||||
|
||||
let square = alpha_logo(50, 50, (10, 10, 30, 30), [0; 3]);
|
||||
assert_eq!(process_logo(&png_bytes(&square)).unwrap().metrics.kind, LogoKind::Mark);
|
||||
assert_eq!(
|
||||
process_logo(&png_bytes(&square)).unwrap().metrics.kind,
|
||||
LogoKind::Mark
|
||||
);
|
||||
let tall = alpha_logo(50, 100, (10, 10, 12, 40), [0; 3]);
|
||||
assert_eq!(process_logo(&png_bytes(&tall)).unwrap().metrics.kind, LogoKind::Tall);
|
||||
assert_eq!(
|
||||
process_logo(&png_bytes(&tall)).unwrap().metrics.kind,
|
||||
LogoKind::Tall
|
||||
);
|
||||
assert_eq!(kind_for(2.49), LogoKind::Mark);
|
||||
assert_eq!(kind_for(0.6), LogoKind::Tall);
|
||||
}
|
||||
@@ -535,15 +557,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sample_logo_is_trimmed_and_small() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/brand/sample-wordmark-logo.png");
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../src/pdf/testing/fixtures/sample-wordmark-logo.png");
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
let out = process_logo(&bytes).unwrap();
|
||||
let m = &out.metrics;
|
||||
assert_eq!((m.source_width, m.source_height), (2172, 724));
|
||||
assert!((4.5..=4.7).contains(&m.aspect), "aspect {}", m.aspect);
|
||||
assert!((0.40..=0.46).contains(&m.ink_density), "density {}", m.ink_density);
|
||||
assert!((5.3..=5.6).contains(&m.aspect), "aspect {}", m.aspect);
|
||||
assert!(
|
||||
(0.50..=0.60).contains(&m.ink_density),
|
||||
"density {}",
|
||||
m.ink_density
|
||||
);
|
||||
assert_eq!(m.kind, LogoKind::Wordmark);
|
||||
assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len());
|
||||
assert!(
|
||||
out.print_png.len() <= 120 * 1024,
|
||||
"print is {} bytes",
|
||||
out.print_png.len()
|
||||
);
|
||||
assert!(out.knockout_png.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,50 @@ pub struct Client {
|
||||
pub po_number: String,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub address_line1: String,
|
||||
#[serde(default)]
|
||||
pub address_line2: String,
|
||||
#[serde(default)]
|
||||
pub city: String,
|
||||
#[serde(default)]
|
||||
pub pincode: String,
|
||||
/// registered_regular | unregistered | composition | sez | overseas. Informational only.
|
||||
#[serde(default = "default_gst_category")]
|
||||
pub gst_category: String,
|
||||
/// Notes / payment terms text copied onto a new invoice when this client is picked.
|
||||
#[serde(default)]
|
||||
pub default_notes: String,
|
||||
/// None = use the settings default.
|
||||
#[serde(default)]
|
||||
pub payment_terms_days: Option<i64>,
|
||||
/// Number of invoices that reference this client; read-only, filled by list_clients.
|
||||
#[serde(default)]
|
||||
pub invoice_count: i64,
|
||||
}
|
||||
|
||||
fn default_gst_category() -> String {
|
||||
"unregistered".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ItemPreset {
|
||||
pub id: Option<i64>,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub hsn_sac: String,
|
||||
/// "fixed" = rate_paise is the line amount; "rate" = rate_paise is the rate per unit.
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
#[serde(default = "default_unit")]
|
||||
pub unit: String,
|
||||
#[serde(default)]
|
||||
pub rate_paise: i64,
|
||||
#[serde(default)]
|
||||
pub sort_order: i64,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -221,6 +265,34 @@ pub struct Invoice {
|
||||
pub archived_fingerprint: Option<String>,
|
||||
pub archived_at: Option<String>,
|
||||
pub items: Vec<InvoiceItem>,
|
||||
/// Derived from the payments table (never stored on the invoice): see commands::payments.
|
||||
#[serde(flatten)]
|
||||
pub payment: PaymentSummary,
|
||||
/// `total` in integer paise.
|
||||
#[serde(default)]
|
||||
pub total_paise: i64,
|
||||
}
|
||||
|
||||
/// What has been received against an invoice. All amounts are integer paise; `payment_status` is
|
||||
/// unpaid | partially_paid | paid | overdue, or `none` for an invoice that takes no payments.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaymentSummary {
|
||||
pub paid_paise: i64,
|
||||
pub tds_paise: i64,
|
||||
pub balance_paise: i64,
|
||||
pub payment_status: String,
|
||||
}
|
||||
|
||||
impl Default for PaymentSummary {
|
||||
fn default() -> Self {
|
||||
PaymentSummary {
|
||||
paid_paise: 0,
|
||||
tds_paise: 0,
|
||||
balance_paise: 0,
|
||||
payment_status: "none".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -234,6 +306,10 @@ pub struct InvoiceSummary {
|
||||
pub total: f64,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub po_number: String,
|
||||
pub total_paise: i64,
|
||||
#[serde(flatten)]
|
||||
pub payment: PaymentSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -7,14 +7,18 @@ use std::path::{Path, PathBuf};
|
||||
const ENV_VAR: &str = "VOICED_SELFTEST_OUT";
|
||||
|
||||
fn configured_path() -> Option<PathBuf> {
|
||||
std::env::var_os(ENV_VAR).filter(|v| !v.is_empty()).map(PathBuf::from)
|
||||
std::env::var_os(ENV_VAR)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn write_report(path: &Path, json: &str) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||
}
|
||||
let mut file = std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?;
|
||||
let mut file =
|
||||
std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?;
|
||||
file.write_all(json.as_bytes())
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|e| format!("Could not write the report: {e}"))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Voiced",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.2",
|
||||
"identifier": "com.bongbetic.voiced",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -6,14 +6,30 @@ import { AppShell, type AppView } from "./components/AppShell";
|
||||
import Onboarding from "./views/Onboarding";
|
||||
import NewInvoice from "./views/NewInvoice";
|
||||
import InvoiceHistory from "./views/InvoiceHistory";
|
||||
import SeriesSettings from "./views/SeriesSettings";
|
||||
import InvoiceDetail from "./views/InvoiceDetail";
|
||||
import Clients from "./views/Clients";
|
||||
import { ToastProvider, useToast } from "./components/ToastProvider";
|
||||
import AppSettings from "./views/AppSettings";
|
||||
import { requestSettingsTab } from "./lib/settingsTab";
|
||||
import { applyDocumentTheme, cachedTheme, normalizeTheme } from "./lib/theme";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AppInner />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [view, setView] = useState<AppView>("new");
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
// A draft made by "Duplicate" that the New invoice form should open once it is shown.
|
||||
const [openDraftId, setOpenDraftId] = useState<number | null>(null);
|
||||
|
||||
const refreshSettings = useCallback(async () => {
|
||||
try {
|
||||
@@ -31,9 +47,15 @@ export default function App() {
|
||||
void 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) {
|
||||
return (
|
||||
<Theme theme="g10">
|
||||
<Theme theme={theme} className="voiced-theme">
|
||||
<div className="voiced-empty">
|
||||
<Loading description="Loading Voiced" withOverlay={false} small />
|
||||
</div>
|
||||
@@ -43,7 +65,7 @@ export default function App() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Theme theme="g10">
|
||||
<Theme theme={theme} className="voiced-theme">
|
||||
<div className="voiced-page">
|
||||
<InlineNotification
|
||||
kind="error"
|
||||
@@ -59,11 +81,9 @@ export default function App() {
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const theme = settings.theme === "g100" ? "g100" : "g10";
|
||||
|
||||
if (!settings.onboarded) {
|
||||
return (
|
||||
<Theme theme={theme}>
|
||||
<Theme theme={theme} className="voiced-theme">
|
||||
<Onboarding
|
||||
initial={settings}
|
||||
onDone={(next) => {
|
||||
@@ -75,31 +95,61 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const openIntegrations = () => {
|
||||
requestSettingsTab("integrations");
|
||||
setView("settings");
|
||||
};
|
||||
|
||||
const toggleTheme = async () => {
|
||||
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
|
||||
setSettings(next);
|
||||
try {
|
||||
await api.saveSettings(next);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not save the theme", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Theme theme={theme}>
|
||||
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
||||
<Theme theme={theme} className="voiced-theme">
|
||||
<AppShell view={view} onNavigate={setView} theme={theme} onToggleTheme={toggleTheme}>
|
||||
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
||||
<NewInvoice
|
||||
settings={settings}
|
||||
onSettingsChange={refreshSettings}
|
||||
active={view === "new"}
|
||||
onActivate={() => setView("new")}
|
||||
openDraftId={openDraftId}
|
||||
onDraftOpened={() => setOpenDraftId(null)}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
<div hidden={view !== "invoices"}>
|
||||
<InvoiceHistory settings={settings} active={view === "invoices"} />
|
||||
<InvoiceHistory
|
||||
settings={settings}
|
||||
active={view === "invoices"}
|
||||
onOpenSettings={openIntegrations}
|
||||
onOpen={(id) => {
|
||||
setDetailId(id);
|
||||
setView("invoice");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div hidden={view !== "series"}>
|
||||
<SeriesSettings active={view === "series"} />
|
||||
{view === "invoice" && detailId !== null ? (
|
||||
<InvoiceDetail
|
||||
key={detailId}
|
||||
invoiceId={detailId}
|
||||
settings={settings}
|
||||
onBack={() => setView("invoices")}
|
||||
onOpenSettings={openIntegrations}
|
||||
onDuplicated={(draftId) => {
|
||||
setOpenDraftId(draftId);
|
||||
setView("new");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div hidden={view !== "clients"}>
|
||||
<Clients active={view === "clients"} />
|
||||
</div>
|
||||
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
||||
</AppShell>
|
||||
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
import { Asleep, Light } from "@carbon/icons-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type AppView = "new" | "invoices" | "series" | "settings";
|
||||
/** "invoice" is the detail page of one invoice; it is reached from "invoices" and highlights that tab. */
|
||||
export type AppView = "new" | "invoices" | "invoice" | "clients" | "settings";
|
||||
|
||||
const NAV: { id: AppView; label: string }[] = [
|
||||
const NAV: { id: Exclude<AppView, "invoice">; label: string }[] = [
|
||||
{ id: "new", label: "New invoice" },
|
||||
{ id: "invoices", label: "Invoices" },
|
||||
{ id: "series", label: "Series" },
|
||||
{ id: "clients", label: "Clients" },
|
||||
{ id: "settings", label: "Settings" },
|
||||
];
|
||||
|
||||
@@ -49,7 +50,7 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
||||
{NAV.map((item) => (
|
||||
<HeaderMenuItem
|
||||
key={item.id}
|
||||
isCurrentPage={view === item.id}
|
||||
isCurrentPage={view === item.id || (view === "invoice" && item.id === "invoices")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onNavigate(item.id);
|
||||
@@ -60,7 +61,11 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
||||
))}
|
||||
</HeaderNavigation>
|
||||
<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} />}
|
||||
</HeaderGlobalAction>
|
||||
</HeaderGlobalBar>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
import { Column, Grid, Modal, TextInput, Toggle } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import type { BankAccount } from "../lib/types";
|
||||
import { ifscError } from "../lib/validators";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
export const blankBank = (): BankAccount => ({
|
||||
id: null,
|
||||
label: "",
|
||||
bankName: "",
|
||||
accountName: "",
|
||||
accountNo: "",
|
||||
branch: "",
|
||||
ifsc: "",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
interface BankAccountModalProps {
|
||||
open: boolean;
|
||||
/** The account to edit; null opens a blank one. */
|
||||
bank: BankAccount | null;
|
||||
onClose: () => void;
|
||||
onSaved: (bank: BankAccount) => void;
|
||||
/** Focus returns here when the modal closes. */
|
||||
launcherRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export default function BankAccountModal({ open, bank, onClose, onSaved, launcherRef }: BankAccountModalProps) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState<BankAccount>(blankBank());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setForm(bank ? { ...bank } : blankBank());
|
||||
setBusy(false);
|
||||
setTouched(false);
|
||||
}, [open, bank]);
|
||||
|
||||
const set = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const bankNameProblem = form.bankName.trim() ? undefined : "Bank name is required";
|
||||
const accountNoProblem = form.accountNo.trim() ? undefined : "Account number is required";
|
||||
const ifscProblem = ifscError(form.ifsc);
|
||||
const blocking = bankNameProblem ?? accountNoProblem ?? ifscProblem;
|
||||
|
||||
const submit = async () => {
|
||||
setTouched(true);
|
||||
if (blocking) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const saved = await api.saveBank({
|
||||
...form,
|
||||
bankName: form.bankName.trim(),
|
||||
accountNo: form.accountNo.trim(),
|
||||
ifsc: form.ifsc.trim().toUpperCase(),
|
||||
});
|
||||
toast.success(form.id === null ? "Bank account added" : "Bank account saved", saved.bankName);
|
||||
onSaved(saved);
|
||||
} catch (e) {
|
||||
toast.error("Could not save bank account", String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
modalHeading={bank?.id ? "Edit bank account" : "New bank account"}
|
||||
primaryButtonText={busy ? "Saving…" : "Save account"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={onClose}
|
||||
onRequestSubmit={() => void submit()}
|
||||
launcherButtonRef={launcherRef}
|
||||
size="md"
|
||||
>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-label"
|
||||
labelText="Label (optional)"
|
||||
value={form.label}
|
||||
onChange={(e) => set("label", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-bankName"
|
||||
labelText="Bank name"
|
||||
value={form.bankName}
|
||||
onChange={(e) => set("bankName", e.target.value)}
|
||||
invalid={touched && Boolean(bankNameProblem)}
|
||||
invalidText={bankNameProblem}
|
||||
data-modal-primary-focus
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-accountName"
|
||||
labelText="Account name"
|
||||
value={form.accountName}
|
||||
onChange={(e) => set("accountName", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-accountNo"
|
||||
labelText="Account number"
|
||||
value={form.accountNo}
|
||||
onChange={(e) => set("accountNo", e.target.value)}
|
||||
invalid={touched && Boolean(accountNoProblem)}
|
||||
invalidText={accountNoProblem}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-branch"
|
||||
labelText="Branch"
|
||||
value={form.branch}
|
||||
onChange={(e) => set("branch", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="bm-ifsc"
|
||||
labelText="IFSC"
|
||||
value={form.ifsc}
|
||||
onChange={(e) => set("ifsc", e.target.value.toUpperCase())}
|
||||
invalid={Boolean(ifscProblem)}
|
||||
invalidText={ifscProblem}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<Toggle
|
||||
id="bm-default"
|
||||
labelText="Use as default account"
|
||||
toggled={form.isDefault}
|
||||
onToggle={(value) => set("isDefault", value)}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
import { Column, Grid, InlineNotification, Modal, Select, SelectItem, TextArea, TextInput } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
GST_CATEGORY_LABELS,
|
||||
INDIAN_STATES,
|
||||
blankClient,
|
||||
type Client,
|
||||
type GstCategory,
|
||||
} from "../lib/types";
|
||||
import { composeAddress, pincodeError, splitLegacyAddress } from "../lib/clientAddress";
|
||||
import { clientGstinStateError } from "../lib/validators";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
interface ClientModalProps {
|
||||
open: boolean;
|
||||
/** The client to edit; null opens a blank one. */
|
||||
client: Client | null;
|
||||
onClose: () => void;
|
||||
onSaved: (client: Client) => void;
|
||||
/** Focus returns here when the modal closes. */
|
||||
launcherRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/** Fills the structured address from the legacy free-text one when a client predates the new fields. */
|
||||
export function prepareForEdit(client: Client): { form: Client; splitFromLegacy: boolean } {
|
||||
const structured = client.addressLine1 || client.addressLine2 || client.city || client.pincode;
|
||||
if (structured || !client.address.trim()) return { form: client, splitFromLegacy: false };
|
||||
return { form: { ...client, ...splitLegacyAddress(client.address) }, splitFromLegacy: true };
|
||||
}
|
||||
|
||||
export default function ClientModal({ open, client, onClose, onSaved, launcherRef }: ClientModalProps) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState<Client>(blankClient());
|
||||
const [split, setSplit] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prepared = prepareForEdit(client ?? blankClient());
|
||||
setForm(prepared.form);
|
||||
setSplit(prepared.splitFromLegacy);
|
||||
setBusy(false);
|
||||
setTouched(false);
|
||||
}, [open, client]);
|
||||
|
||||
const set = <K extends keyof Client>(key: K, value: Client[K]) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const nameProblem = form.name.trim() ? undefined : "Client name is required";
|
||||
const gstinProblem = clientGstinStateError(form.gstin, form.stateCode);
|
||||
const pinProblem = pincodeError(form.pincode);
|
||||
const termsProblem =
|
||||
form.paymentTermsDays !== null && (form.paymentTermsDays < 0 || form.paymentTermsDays > 365)
|
||||
? "Between 0 and 365 days"
|
||||
: undefined;
|
||||
const blocking = nameProblem ?? gstinProblem ?? pinProblem ?? termsProblem;
|
||||
|
||||
const submit = async () => {
|
||||
setTouched(true);
|
||||
if (blocking) return;
|
||||
setBusy(true);
|
||||
const structured = form.addressLine1 || form.addressLine2 || form.city || form.pincode;
|
||||
const toSave: Client = {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
gstin: form.gstin.trim().toUpperCase(),
|
||||
address: structured
|
||||
? composeAddress({
|
||||
line1: form.addressLine1,
|
||||
line2: form.addressLine2,
|
||||
city: form.city,
|
||||
stateCode: form.stateCode,
|
||||
pincode: form.pincode,
|
||||
})
|
||||
: form.address,
|
||||
};
|
||||
try {
|
||||
const saved = await api.saveClient(toSave);
|
||||
toast.success(toSave.id === null ? "Client added" : "Client updated", saved.name);
|
||||
onSaved(saved);
|
||||
} catch (e) {
|
||||
toast.error("Could not save client", String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
modalHeading={client?.id ? "Edit client" : "New client"}
|
||||
primaryButtonText={busy ? "Saving…" : "Save client"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={onClose}
|
||||
onRequestSubmit={() => void submit()}
|
||||
launcherButtonRef={launcherRef}
|
||||
size="md"
|
||||
>
|
||||
<Grid narrow>
|
||||
{split ? (
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<InlineNotification
|
||||
kind="info"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="Address split automatically"
|
||||
subtitle="This client has a free-text address. Check the lines below before saving."
|
||||
/>
|
||||
</Column>
|
||||
) : null}
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextInput
|
||||
id="cm-name"
|
||||
labelText="Client name"
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
invalid={touched && Boolean(nameProblem)}
|
||||
invalidText={nameProblem}
|
||||
data-modal-primary-focus
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<Select
|
||||
id="cm-category"
|
||||
labelText="GST category"
|
||||
value={form.gstCategory}
|
||||
onChange={(e) => set("gstCategory", e.target.value as GstCategory)}
|
||||
>
|
||||
{Object.entries(GST_CATEGORY_LABELS).map(([value, text]) => (
|
||||
<SelectItem key={value} value={value} text={text} />
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="cm-gstin"
|
||||
labelText="GSTIN (optional)"
|
||||
value={form.gstin}
|
||||
onChange={(e) => set("gstin", e.target.value.toUpperCase())}
|
||||
invalid={Boolean(form.gstin.trim()) && Boolean(gstinProblem)}
|
||||
invalidText={gstinProblem}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextInput
|
||||
id="cm-line1"
|
||||
labelText="Address line 1"
|
||||
value={form.addressLine1}
|
||||
onChange={(e) => set("addressLine1", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextInput
|
||||
id="cm-line2"
|
||||
labelText="Address line 2"
|
||||
value={form.addressLine2}
|
||||
onChange={(e) => set("addressLine2", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput id="cm-city" labelText="City" value={form.city} onChange={(e) => set("city", e.target.value)} />
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={7}>
|
||||
<Select
|
||||
id="cm-state"
|
||||
labelText="State (also the default place of supply)"
|
||||
value={form.stateCode}
|
||||
onChange={(e) => set("stateCode", e.target.value)}
|
||||
>
|
||||
<SelectItem value="" text="Not set" />
|
||||
{INDIAN_STATES.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={4}>
|
||||
<TextInput
|
||||
id="cm-pin"
|
||||
labelText="Pincode"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={form.pincode}
|
||||
onChange={(e) => set("pincode", e.target.value)}
|
||||
invalid={Boolean(pinProblem)}
|
||||
invalidText={pinProblem}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="cm-po"
|
||||
labelText="Default PO number (optional)"
|
||||
value={form.poNumber}
|
||||
onChange={(e) => set("poNumber", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="cm-terms"
|
||||
labelText="Payment terms (days, blank = default)"
|
||||
inputMode="numeric"
|
||||
value={form.paymentTermsDays === null ? "" : String(form.paymentTermsDays)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
set("paymentTermsDays", raw === "" ? null : Number.isFinite(Number(raw)) ? Math.trunc(Number(raw)) : null);
|
||||
}}
|
||||
invalid={Boolean(termsProblem)}
|
||||
invalidText={termsProblem}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextArea
|
||||
id="cm-notes"
|
||||
labelText="Default notes / payment terms text"
|
||||
helperText="Copied into the invoice notes when this client is picked and the notes are empty."
|
||||
rows={3}
|
||||
value={form.defaultNotes}
|
||||
onChange={(e) => set("defaultNotes", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
|
||||
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import type { Settings } from "../lib/types";
|
||||
import {
|
||||
backupFileName,
|
||||
ensureZipExtension,
|
||||
formatBytes,
|
||||
formatWhen,
|
||||
type BackupStatus,
|
||||
type PendingRestore,
|
||||
} from "../lib/backup";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
const ZIP_FILTER = [{ name: "Voiced backup", extensions: ["zip"] }];
|
||||
|
||||
type RestoreStep = "confirm" | "staging" | "ready";
|
||||
|
||||
const joinPath = (dir: string, name: string) =>
|
||||
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
||||
|
||||
interface DataBackupPanelProps {
|
||||
/** The saved settings; "Run setup again" writes them back with the wizard flag cleared. */
|
||||
settings: Settings;
|
||||
/** Called after the flag is cleared, so the app reloads settings and shows the wizard. */
|
||||
onRerunSetup: () => void;
|
||||
}
|
||||
|
||||
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */
|
||||
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
|
||||
const toast = useToast();
|
||||
const [status, setStatus] = useState<BackupStatus | null>(null);
|
||||
const [backingUp, setBackingUp] = useState(false);
|
||||
const [restorePath, setRestorePath] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<RestoreStep>("confirm");
|
||||
const [staged, setStaged] = useState<PendingRestore | null>(null);
|
||||
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
useReturnFocus(restorePath !== null);
|
||||
|
||||
const rerunSetup = async () => {
|
||||
const ok = await confirm(
|
||||
"Open the setup wizard again? Your invoices, clients and backups are kept, and the wizard starts with your current details.",
|
||||
{ title: "Run setup again", kind: "warning" },
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.saveSettings({ ...settings, onboarded: false });
|
||||
onRerunSetup();
|
||||
} catch (e) {
|
||||
toast.error("Could not start setup", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await api.getBackupStatus());
|
||||
} catch (e) {
|
||||
toast.error("Could not load backup status", String(e));
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const backUpNow = async () => {
|
||||
setBackingUp(true);
|
||||
try {
|
||||
let dir = "";
|
||||
try {
|
||||
dir = await api.getLastExportDir();
|
||||
} catch {
|
||||
// No remembered folder: the dialog opens wherever the system prefers.
|
||||
}
|
||||
const chosen = await save({ defaultPath: joinPath(dir, backupFileName()), filters: ZIP_FILTER });
|
||||
if (!chosen) return;
|
||||
const summary = await api.createBackup(ensureZipExtension(chosen));
|
||||
toast.success("Backup saved", `${summary.fileCount} files, ${formatBytes(summary.totalBytes)}: ${summary.path}`);
|
||||
} catch (e) {
|
||||
toast.error("Backup failed", String(e));
|
||||
} finally {
|
||||
setBackingUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseRestoreFile = async () => {
|
||||
try {
|
||||
const picked = await open({ multiple: false, directory: false, filters: ZIP_FILTER });
|
||||
if (typeof picked !== "string") return;
|
||||
setRestoreError(null);
|
||||
setStaged(null);
|
||||
setStep("confirm");
|
||||
setRestorePath(picked);
|
||||
} catch (e) {
|
||||
toast.error("Could not open the file picker", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const closeRestore = () => {
|
||||
if (step === "staging") return;
|
||||
setRestorePath(null);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const stageRestore = async () => {
|
||||
if (!restorePath) return;
|
||||
setStep("staging");
|
||||
setRestoreError(null);
|
||||
try {
|
||||
setStaged(await api.restoreBackup(restorePath));
|
||||
setStep("ready");
|
||||
} catch (e) {
|
||||
setRestoreError(String(e));
|
||||
setStep("confirm");
|
||||
toast.error("This backup cannot be restored", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const restart = async () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
await api.restartApp();
|
||||
} catch (e) {
|
||||
setRestarting(false);
|
||||
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the restore.`);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelPending = async () => {
|
||||
try {
|
||||
await api.cancelPendingRestore();
|
||||
toast.info("Restore cancelled", "Your data was not changed.");
|
||||
} catch (e) {
|
||||
toast.error("Could not cancel the restore", String(e));
|
||||
}
|
||||
setRestorePath(null);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const toggleAuto = async (enabled: boolean) => {
|
||||
try {
|
||||
const saved = await api.setAutoBackup(enabled);
|
||||
setStatus((prev) => (prev ? { ...prev, autoBackup: saved } : prev));
|
||||
toast.success(saved ? "Automatic backup on" : "Automatic backup off");
|
||||
} catch (e) {
|
||||
toast.error("Could not change automatic backup", String(e));
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const openFolder = async (path: string) => {
|
||||
try {
|
||||
await api.openFile(path);
|
||||
} catch (e) {
|
||||
toast.error("Could not open the folder", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const pending = status?.pendingRestore ?? null;
|
||||
|
||||
return (
|
||||
<section className="voiced-stack">
|
||||
{pending ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="A restore is waiting for a restart"
|
||||
subtitle={`${pending.backupName} (${pending.fileCount} files, ${formatBytes(pending.totalBytes)}) has been checked. Restart Voiced to switch to it; your current data is kept in a safety copy.`}
|
||||
/>
|
||||
) : null}
|
||||
{pending ? (
|
||||
<div className="voiced-actions">
|
||||
<Button size="md" onClick={() => void restart()} disabled={restarting}>
|
||||
{restarting ? "Restarting…" : "Restart now"}
|
||||
</Button>
|
||||
<Button size="md" kind="tertiary" onClick={() => void cancelPending()} disabled={restarting}>
|
||||
Cancel restore
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Tile>
|
||||
<h2 className="voiced-section-title">Back up</h2>
|
||||
<p className="voiced-muted">
|
||||
Saves one zip with your invoices, clients, payments, settings, logo and signature images, archived PDFs and imported
|
||||
fonts. Keep a copy somewhere other than this computer.
|
||||
</p>
|
||||
<div className="voiced-actions">
|
||||
<Button size="md" onClick={() => void backUpNow()} disabled={backingUp}>
|
||||
{backingUp ? "Backing up…" : "Back up now"}
|
||||
</Button>
|
||||
{backingUp ? <InlineLoading description="Writing the backup" /> : null}
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<h2 className="voiced-section-title">Restore from backup</h2>
|
||||
<p className="voiced-muted">
|
||||
Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first,
|
||||
never deleted.
|
||||
</p>
|
||||
<div className="voiced-actions">
|
||||
<Button size="md" kind="danger--tertiary" onClick={() => void chooseRestoreFile()} disabled={restorePath !== null || !!pending}>
|
||||
Restore from backup…
|
||||
</Button>
|
||||
</div>
|
||||
{status?.lastRestore ? (
|
||||
<p className="voiced-muted" role="status">
|
||||
{status.lastRestore.ok
|
||||
? `Last restore: ${status.lastRestore.backupName || "a backup"}, ${formatWhen(status.lastRestore.at)}.`
|
||||
: `The last restore attempt (${formatWhen(status.lastRestore.at)}) did not go through: ${status.lastRestore.message}`}{" "}
|
||||
{status.lastRestore.safetyDir ? (
|
||||
<Button kind="ghost" size="sm" onClick={() => void api.revealInFolder(status.lastRestore!.safetyDir!).catch((e) => toast.error("Could not open the folder", String(e)))}>
|
||||
Show safety copy
|
||||
</Button>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<h2 className="voiced-section-title">Automatic backup</h2>
|
||||
<Toggle
|
||||
id="autoBackup"
|
||||
labelText="Back up automatically every day"
|
||||
labelA="Off"
|
||||
labelB="On"
|
||||
toggled={status?.autoBackup ?? true}
|
||||
disabled={!status}
|
||||
onToggle={(checked: boolean) => void toggleAuto(checked)}
|
||||
/>
|
||||
<p className="voiced-muted">
|
||||
While Voiced is open it writes one backup a day into its data folder and keeps the newest 14. This changes right away; you
|
||||
do not need to click Save settings.
|
||||
</p>
|
||||
<p role="status">
|
||||
{status === null
|
||||
? "Loading…"
|
||||
: status.lastAutoBackup
|
||||
? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).`
|
||||
: "No automatic backup yet."}
|
||||
</p>
|
||||
<div className="voiced-actions">
|
||||
<Button size="md" kind="tertiary" disabled={!status} onClick={() => status && void openFolder(status.autoDir)}>
|
||||
Open backup folder
|
||||
</Button>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<h2 className="voiced-section-title">Setup wizard</h2>
|
||||
<p className="voiced-muted">
|
||||
Run the first-time setup again to change your business details, logo, bank account and numbering in one go. Existing
|
||||
invoices, clients and backups are not touched, and invoice numbering carries on unless you change the prefix.
|
||||
</p>
|
||||
<div className="voiced-actions">
|
||||
<Button size="md" kind="tertiary" onClick={() => void rerunSetup()}>
|
||||
Run setup again
|
||||
</Button>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Modal
|
||||
open={restorePath !== null}
|
||||
danger={step === "confirm"}
|
||||
modalHeading={step === "ready" ? "Restart to finish restoring" : "Restore from this backup?"}
|
||||
primaryButtonText={
|
||||
step === "confirm" ? "Check and restore" : step === "staging" ? "Checking the backup…" : restarting ? "Restarting…" : "Restart now"
|
||||
}
|
||||
secondaryButtonText={step === "ready" ? "Later" : "Cancel"}
|
||||
primaryButtonDisabled={step === "staging" || restarting}
|
||||
onRequestClose={closeRestore}
|
||||
onRequestSubmit={() => void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
|
||||
onSecondarySubmit={closeRestore}
|
||||
>
|
||||
<div className="voiced-stack">
|
||||
{restoreError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Cannot restore" subtitle={restoreError} /> : null}
|
||||
{step === "ready" && staged ? (
|
||||
<>
|
||||
<p>
|
||||
<strong>{staged.backupName}</strong> passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
|
||||
{formatWhen(staged.backupCreatedAt)}.
|
||||
</p>
|
||||
<p>
|
||||
Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
|
||||
when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
File: <strong>{restorePath}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
|
||||
contents of this backup.
|
||||
</p>
|
||||
<ul className="voiced-list">
|
||||
<li>The file is checked first; a damaged or newer-version backup is refused and nothing changes.</li>
|
||||
<li>Your current data is moved to a safety copy in Voiced’s backups folder before the swap. It is never deleted.</li>
|
||||
<li>Voiced restarts to complete the restore.</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ComboBox,
|
||||
FileUploaderButton,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
PasswordInput,
|
||||
RadioButton,
|
||||
RadioButtonGroup,
|
||||
TextArea,
|
||||
TextInput,
|
||||
Tag,
|
||||
Tile,
|
||||
Toggle,
|
||||
} from "@carbon/react";
|
||||
import { Save } from "@carbon/icons-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { ErpnextConfig, ErpnextConfigInput, ErpnextConnectionTest, ErpnextOptionItem, ErpnextOptions } from "../lib/erpnext";
|
||||
import {
|
||||
OPTION_LIST_LABELS,
|
||||
UOM_UNITS,
|
||||
connectionProblems,
|
||||
emptyOptions,
|
||||
hasProblems,
|
||||
mirrorSupported,
|
||||
optionErrors,
|
||||
optionText,
|
||||
sortWarnings,
|
||||
toInput,
|
||||
validateConfigForm,
|
||||
versionsLine,
|
||||
warningSeverity,
|
||||
withStoredValue,
|
||||
type FormProblems,
|
||||
} from "../lib/erpnextUi";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
|
||||
|
||||
interface PickProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** The loaded list; an empty list means "not loaded", and the field takes typed text instead. */
|
||||
items: ErpnextOptionItem[];
|
||||
/** Why this list is empty although the lists were loaded (a load error for this list). */
|
||||
error?: string;
|
||||
/** The control is switched off, with the reason shown under it. */
|
||||
disabledReason?: string;
|
||||
helper?: string;
|
||||
}
|
||||
|
||||
/** A searchable dropdown over a live list, or a text box while the list has not been loaded. */
|
||||
function PickField({ id, label, value, onChange, items, error, disabledReason, helper }: PickProps) {
|
||||
const options = useMemo(() => withStoredValue(items, value), [items, value]);
|
||||
const note = disabledReason ?? error ?? helper;
|
||||
if (items.length === 0 || disabledReason) {
|
||||
return (
|
||||
<TextInput
|
||||
id={id}
|
||||
labelText={label}
|
||||
value={value}
|
||||
disabled={Boolean(disabledReason)}
|
||||
invalid={Boolean(error) && !disabledReason}
|
||||
invalidText={error}
|
||||
helperText={error ? undefined : note}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ComboBox
|
||||
id={id}
|
||||
titleText={label}
|
||||
items={options}
|
||||
itemToString={(i: ErpnextOptionItem | null) => (i ? optionText(i) : "")}
|
||||
selectedItem={options.find((o) => o.name === value) ?? null}
|
||||
onChange={({ selectedItem }: { selectedItem?: ErpnextOptionItem | null }) => onChange(selectedItem?.name ?? "")}
|
||||
shouldFilterItem={({ item, inputValue }: { item: ErpnextOptionItem; inputValue: string | null }) =>
|
||||
!inputValue || optionText(item).toLowerCase().includes(inputValue.toLowerCase())
|
||||
}
|
||||
helperText={helper}
|
||||
placeholder="Search or choose"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children, note }: { title: string; children: React.ReactNode; note?: string }) {
|
||||
return (
|
||||
<section className="voiced-erp__section" aria-label={title}>
|
||||
<h3>{title}</h3>
|
||||
{note ? <p className="voiced-muted">{note}</p> : null}
|
||||
<div className="voiced-erp__fields">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings, Integrations, ERPNext. It has its own Save: the page-level Save settings button does not cover it. */
|
||||
export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
|
||||
const toast = useToast();
|
||||
const [saved, setSaved] = useState<ErpnextConfig | null>(null);
|
||||
const [form, setForm] = useState<ErpnextConfigInput | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const [test, setTest] = useState<ErpnextConnectionTest | null>(null);
|
||||
/** A test result from this session that is not saved yet. */
|
||||
const [testUnsaved, setTestUnsaved] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [options, setOptions] = useState<ErpnextOptions | null>(null);
|
||||
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showProblems, setShowProblems] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await api.erpnextGetConfig();
|
||||
setSaved(cfg);
|
||||
setForm(toInput(cfg));
|
||||
setTest(cfg.lastDetectResult);
|
||||
setTestUnsaved(false);
|
||||
setLoadError(null);
|
||||
} catch (e) {
|
||||
setLoadError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && form === null) void load();
|
||||
// Reload only on first show; edits must survive switching tabs.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
|
||||
if (loadError) {
|
||||
return <InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the ERPNext settings" subtitle={loadError} />;
|
||||
}
|
||||
if (!form || !saved) return <InlineLoading description="Loading ERPNext settings" />;
|
||||
|
||||
const set = <K extends keyof ErpnextConfigInput>(key: K, value: ErpnextConfigInput[K]) => setForm((f) => (f ? { ...f, [key]: value } : f));
|
||||
const secretStored = saved.apiSecretSet;
|
||||
const secretShown = secretStored && !replacing && !form.clearSecret;
|
||||
const problems: FormProblems = showProblems ? validateConfigForm(form, secretStored, test) : {};
|
||||
const connProblems = connectionProblems(form, secretStored);
|
||||
const canConnect = !hasProblems(connProblems);
|
||||
const optErrors = optionErrors(options);
|
||||
const opts = options ?? emptyOptions();
|
||||
const noCompany = !form.company.trim();
|
||||
const companyReason = noCompany ? "Pick a company first." : undefined;
|
||||
const seriesMode = form.namingMode === "series";
|
||||
const mirrorOk = mirrorSupported(test);
|
||||
|
||||
const loadOptions = async (values: ErpnextConfigInput) => {
|
||||
setLoadingOptions(true);
|
||||
try {
|
||||
setOptions(await api.erpnextLoadOptions(values));
|
||||
} catch (e) {
|
||||
setOptions(null);
|
||||
toast.error("Could not load the ERPNext lists", String(e));
|
||||
} finally {
|
||||
setLoadingOptions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
setShowProblems(true);
|
||||
if (!canConnect) return;
|
||||
setTesting(true);
|
||||
setTestError(null);
|
||||
try {
|
||||
const result = await api.erpnextTestConnection(form);
|
||||
setTest(result);
|
||||
setTestUnsaved(true);
|
||||
let next = form;
|
||||
// A site that keeps no posted name cannot mirror the Voiced number.
|
||||
if (!result.features.v2Naming && form.namingMode === "mirror") {
|
||||
next = { ...form, namingMode: "series" };
|
||||
setForm(next);
|
||||
}
|
||||
await loadOptions(next);
|
||||
} catch (e) {
|
||||
setTestError(String(e));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeCompany = (company: string) => {
|
||||
const next = { ...form, company };
|
||||
setForm(next);
|
||||
// Accounts, addresses and cost centers belong to the company, so the lists are reloaded for it.
|
||||
if (options && canConnect) void loadOptions(next);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setShowProblems(true);
|
||||
setSaveError(null);
|
||||
if (hasProblems(validateConfigForm(form, secretStored, test))) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const cfg = await api.erpnextSaveConfig({ ...form, ...(testUnsaved && test ? { lastDetectResult: test } : {}) });
|
||||
setSaved(cfg);
|
||||
setForm(toInput(cfg));
|
||||
setReplacing(false);
|
||||
setTest(cfg.lastDetectResult ?? test);
|
||||
setTestUnsaved(false);
|
||||
setShowProblems(false);
|
||||
toast.success("ERPNext settings saved");
|
||||
} catch (e) {
|
||||
const text = String(e);
|
||||
setSaveError(text);
|
||||
toast.error("Could not save the ERPNext settings", text);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const readPem = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
set("extraCaPem", (await file.text()).trim());
|
||||
} catch (e) {
|
||||
toast.error("Could not read the certificate file", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const secretInvalid = problems.apiSecret ?? (saveError && /secret again/i.test(saveError) ? saveError : undefined);
|
||||
|
||||
return (
|
||||
<div className="voiced-stack voiced-erp">
|
||||
<div>
|
||||
<h2 className="voiced-detail__h2">ERPNext</h2>
|
||||
<p>
|
||||
Send issued invoices and payments to your ERPNext site as Sales Invoices. Everything is sent from this computer; ERPNext is never
|
||||
needed to issue an invoice.
|
||||
</p>
|
||||
<p className="voiced-muted">
|
||||
Create a dedicated ERPNext user for Voiced with only these permissions: create and submit Sales Invoice, read on the master data
|
||||
(Customer, Item, Address, Account, Company), and write on File. Use that user’s API key and secret below. An address must be
|
||||
https://; plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test hosts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Section title="Connection">
|
||||
<TextInput
|
||||
id="erp-baseUrl"
|
||||
labelText="Site address"
|
||||
placeholder="https://erp.example.com"
|
||||
value={form.baseUrl}
|
||||
invalid={Boolean(problems.baseUrl)}
|
||||
invalidText={problems.baseUrl}
|
||||
onChange={(e) => set("baseUrl", e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
id="erp-apiKey"
|
||||
labelText="API key"
|
||||
autoComplete="off"
|
||||
value={form.apiKey}
|
||||
invalid={Boolean(problems.apiKey)}
|
||||
invalidText={problems.apiKey}
|
||||
onChange={(e) => set("apiKey", e.target.value)}
|
||||
/>
|
||||
{secretShown ? (
|
||||
<div className="voiced-erp__secret">
|
||||
<p className="voiced-erp__label" id="erp-secret-label">
|
||||
API secret
|
||||
</p>
|
||||
<p aria-labelledby="erp-secret-label">
|
||||
<Tag type="green" size="sm">
|
||||
set
|
||||
</Tag>{" "}
|
||||
<span className="voiced-muted">The secret is stored on this computer and is never shown again.</span>
|
||||
</p>
|
||||
<div className="voiced-header-action">
|
||||
<Button kind="tertiary" size="sm" onClick={() => setReplacing(true)}>
|
||||
Replace
|
||||
</Button>
|
||||
<Button kind="ghost" size="sm" onClick={() => set("clearSecret", true)}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="erp-apiSecret"
|
||||
labelText="API secret"
|
||||
autoComplete="new-password"
|
||||
value={form.apiSecret}
|
||||
invalid={Boolean(secretInvalid)}
|
||||
invalidText={secretInvalid}
|
||||
helperText={form.clearSecret ? "The stored secret is removed when you save, unless you type a new one." : undefined}
|
||||
onChange={(e) => set("apiSecret", e.target.value)}
|
||||
/>
|
||||
{secretStored ? (
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReplacing(false);
|
||||
setForm((f) => (f ? { ...f, apiSecret: "", clearSecret: false } : f));
|
||||
}}
|
||||
>
|
||||
Keep the stored secret
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<TextArea
|
||||
id="erp-extraCa"
|
||||
labelText="Extra CA certificate (optional, PEM)"
|
||||
helperText="Only for a self-hosted site whose certificate is not trusted by this computer."
|
||||
rows={3}
|
||||
value={form.extraCaPem}
|
||||
onChange={(e) => set("extraCaPem", e.target.value)}
|
||||
/>
|
||||
<FileUploaderButton
|
||||
labelText="Load from file…"
|
||||
buttonKind="ghost"
|
||||
size="sm"
|
||||
accept={[".pem", ".crt", ".cer"]}
|
||||
disableLabelChanges
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
void readPem(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="voiced-header-action">
|
||||
<Button kind="secondary" size="md" onClick={() => void runTest()} disabled={testing || loadingOptions}>
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
</Button>
|
||||
{testing || loadingOptions ? <InlineLoading description={testing ? "Contacting ERPNext" : "Loading lists"} /> : null}
|
||||
{!canConnect && showProblems ? <span className="voiced-muted">Fix the address, key and secret first.</span> : null}
|
||||
</div>
|
||||
{testError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Connection failed" subtitle={testError} /> : null}
|
||||
{test ? (
|
||||
<Tile className="voiced-erp__result" aria-label="Connection test result">
|
||||
<p>
|
||||
<strong>Connected as {test.user || "an unknown user"}</strong>{" "}
|
||||
<Tag type={test.indiaCompliance ? "green" : "warm-gray"} size="sm">
|
||||
{test.indiaCompliance ? "India Compliance detected" : "India Compliance not detected"}
|
||||
</Tag>
|
||||
</p>
|
||||
<p className="voiced-muted">{versionsLine(test)}</p>
|
||||
{testUnsaved ? (
|
||||
<InlineNotification
|
||||
kind="info"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="Save to keep this result"
|
||||
subtitle="Sending invoices needs a saved connection test."
|
||||
/>
|
||||
) : (
|
||||
<p className="voiced-muted">Last saved test result.</p>
|
||||
)}
|
||||
{sortWarnings(test.warnings).map((w, i) => {
|
||||
const severity = warningSeverity(w.code);
|
||||
return (
|
||||
<InlineNotification
|
||||
key={`${w.code}-${i}`}
|
||||
kind={severity}
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={SEVERITY_TITLE[severity]}
|
||||
subtitle={w.message}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{test.warnings.length === 0 ? <p className="voiced-muted">No problems found with the current settings.</p> : null}
|
||||
<p className="voiced-muted">Test again after changing the mappings below to refresh these warnings.</p>
|
||||
</Tile>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Company and accounts"
|
||||
note={options ? "Choose from what your ERPNext site has." : "Run Test connection to choose from your site's lists; until then, type the exact names."}
|
||||
>
|
||||
{options && options.errors.length > 0 ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="Some lists could not be loaded"
|
||||
subtitle={options.errors.map((e) => `${OPTION_LIST_LABELS[e.list] ?? e.list}: ${e.message}`).join(" ")}
|
||||
/>
|
||||
) : null}
|
||||
<PickField
|
||||
id="erp-company"
|
||||
label="Company"
|
||||
value={form.company}
|
||||
onChange={changeCompany}
|
||||
items={opts.companies}
|
||||
error={optErrors.get("companies")}
|
||||
/>
|
||||
<PickField
|
||||
id="erp-companyAddress"
|
||||
label="Company address"
|
||||
value={form.companyAddress}
|
||||
onChange={(v) => set("companyAddress", v)}
|
||||
items={opts.companyAddresses}
|
||||
error={optErrors.get("companyAddresses")}
|
||||
disabledReason={companyReason}
|
||||
helper="Required by India Compliance on every invoice."
|
||||
/>
|
||||
<PickField
|
||||
id="erp-incomeAccount"
|
||||
label="Income account"
|
||||
value={form.incomeAccount}
|
||||
onChange={(v) => set("incomeAccount", v)}
|
||||
items={opts.incomeAccounts}
|
||||
error={optErrors.get("incomeAccounts")}
|
||||
disabledReason={companyReason}
|
||||
/>
|
||||
<PickField
|
||||
id="erp-costCenter"
|
||||
label="Cost center"
|
||||
value={form.costCenter}
|
||||
onChange={(v) => set("costCenter", v)}
|
||||
items={opts.costCenters}
|
||||
error={optErrors.get("costCenters")}
|
||||
disabledReason={companyReason}
|
||||
/>
|
||||
{(
|
||||
[
|
||||
["cgstAccount", "CGST account"],
|
||||
["sgstAccount", "SGST account"],
|
||||
["utgstAccount", "UTGST account"],
|
||||
["igstAccount", "IGST account"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<PickField
|
||||
key={key}
|
||||
id={`erp-${key}`}
|
||||
label={label}
|
||||
value={form[key]}
|
||||
onChange={(v) => set(key, v)}
|
||||
items={opts.taxAccounts}
|
||||
error={optErrors.get("taxAccounts")}
|
||||
disabledReason={companyReason}
|
||||
helper={key === "utgstAccount" ? "Only needed when the supplier is in a union territory without a legislature." : "Must be the account set in GST Settings."}
|
||||
/>
|
||||
))}
|
||||
<TextInput
|
||||
id="erp-paymentBank"
|
||||
labelText="Payment bank account"
|
||||
helperText="The bank or cash account that receives payments, e.g. HDFC Bank - AC. Needed to send payments."
|
||||
value={form.paymentBankAccount}
|
||||
onChange={(e) => set("paymentBankAccount", e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
id="erp-tdsAccount"
|
||||
labelText="TDS account"
|
||||
helperText="The account that holds tax deducted by clients. Needed only for payments with TDS."
|
||||
value={form.tdsAccount}
|
||||
onChange={(e) => set("tdsAccount", e.target.value)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Customers and items">
|
||||
<TextInput
|
||||
id="erp-defaultItem"
|
||||
labelText="Default item code (optional)"
|
||||
helperText="Used for invoice lines without an item code of their own; a line with none is sent as a description-only row."
|
||||
value={form.defaultItemCode}
|
||||
onChange={(e) => set("defaultItemCode", e.target.value)}
|
||||
/>
|
||||
<PickField
|
||||
id="erp-customerGroup"
|
||||
label="Customer group for new customers"
|
||||
value={form.customerGroup}
|
||||
onChange={(v) => set("customerGroup", v)}
|
||||
items={opts.customerGroups}
|
||||
error={optErrors.get("customerGroups")}
|
||||
/>
|
||||
<PickField
|
||||
id="erp-territory"
|
||||
label="Territory for new customers"
|
||||
value={form.territory}
|
||||
onChange={(v) => set("territory", v)}
|
||||
items={opts.territories}
|
||||
error={optErrors.get("territories")}
|
||||
/>
|
||||
<PickField
|
||||
id="erp-priceList"
|
||||
label="Selling price list"
|
||||
value={form.sellingPriceList}
|
||||
onChange={(v) => set("sellingPriceList", v)}
|
||||
items={opts.priceLists}
|
||||
error={optErrors.get("priceLists")}
|
||||
/>
|
||||
<fieldset className="voiced-erp__uoms">
|
||||
<legend>Units of measure</legend>
|
||||
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.</p>
|
||||
{UOM_UNITS.map((unit) => (
|
||||
<PickField
|
||||
key={unit}
|
||||
id={`erp-uom-${unit}`}
|
||||
label={`Voiced ${unit}`}
|
||||
value={form.uomMap[unit] ?? ""}
|
||||
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
|
||||
items={opts.uoms}
|
||||
error={optErrors.get("uoms")}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
</Section>
|
||||
|
||||
<Section title="Invoice numbers">
|
||||
<RadioButtonGroup
|
||||
legendText="Numbering in ERPNext"
|
||||
name="erp-namingMode"
|
||||
orientation="vertical"
|
||||
valueSelected={form.namingMode}
|
||||
onChange={(value) => set("namingMode", value === "series" ? "series" : "mirror")}
|
||||
>
|
||||
<RadioButton
|
||||
id="erp-nm-mirror"
|
||||
value="mirror"
|
||||
labelText="Mirror the Voiced number (recommended)"
|
||||
disabled={!mirrorOk}
|
||||
/>
|
||||
<RadioButton id="erp-nm-series" value="series" labelText="Use an ERPNext naming series" />
|
||||
</RadioButtonGroup>
|
||||
<p className="voiced-muted">
|
||||
{mirrorOk
|
||||
? "Mirror keeps the number printed on the PDF as the ERPNext invoice name, so GST returns match. Needs Frappe 15.73 or later."
|
||||
: "Mirror is unavailable: this ERPNext version replaces a posted invoice number. The Voiced number is stored in the remarks instead."}
|
||||
</p>
|
||||
{problems.namingMode ? <p className="voiced-erp__error">{problems.namingMode}</p> : null}
|
||||
<PickField
|
||||
id="erp-namingSeries"
|
||||
label="Naming series"
|
||||
value={form.namingSeries}
|
||||
onChange={(v) => set("namingSeries", v)}
|
||||
items={opts.namingSeries}
|
||||
error={problems.namingSeries ?? optErrors.get("namingSeries")}
|
||||
disabledReason={seriesMode ? undefined : "Only used with an ERPNext naming series."}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Sending">
|
||||
<Toggle
|
||||
id="erp-submit"
|
||||
labelText="Submit invoices when sending"
|
||||
labelA="Off: drafts for review"
|
||||
labelB="On: submitted"
|
||||
toggled={form.submitOnPush}
|
||||
onToggle={(v) => set("submitOnPush", v)}
|
||||
/>
|
||||
<Toggle
|
||||
id="erp-attach"
|
||||
labelText="Attach the PDF"
|
||||
labelA="Off"
|
||||
labelB="On"
|
||||
toggled={form.attachPdf}
|
||||
onToggle={(v) => set("attachPdf", v)}
|
||||
/>
|
||||
<Toggle
|
||||
id="erp-auto"
|
||||
labelText="Send automatically when an invoice is issued"
|
||||
labelA="Off"
|
||||
labelB="On"
|
||||
toggled={form.autoPushOnIssue}
|
||||
onToggle={(v) => set("autoPushOnIssue", v)}
|
||||
/>
|
||||
<Toggle
|
||||
id="erp-customers"
|
||||
labelText="Create customers that are missing in ERPNext"
|
||||
labelA="Off"
|
||||
labelB="On"
|
||||
toggled={form.createMissingCustomers}
|
||||
onToggle={(v) => set("createMissingCustomers", v)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{saveError && !secretInvalid ? <InlineNotification kind="error" lowContrast hideCloseButton title="Not saved" subtitle={saveError} /> : null}
|
||||
<div>
|
||||
<Button renderIcon={Save} onClick={() => void save()} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save ERPNext settings"}
|
||||
</Button>
|
||||
<p className="voiced-muted">This tab has its own Save button; Save settings at the bottom of the page does not cover it.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
Grid,
|
||||
InlineLoading,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from "@carbon/react";
|
||||
import { Add, Edit, TrashCan } from "@carbon/icons-react";
|
||||
import { api } from "../lib/api";
|
||||
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types";
|
||||
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
|
||||
import { formatAmount } from "../lib/format";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
/** Saved line items: description, HSN/SAC, type, unit and amount (kept as integer paise). */
|
||||
export default function ItemPresetsPanel({ active }: { active: boolean }) {
|
||||
const toast = useToast();
|
||||
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<ItemPreset | null>(null);
|
||||
const [deleting, setDeleting] = useState<ItemPreset | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [touched, setTouched] = useState(false);
|
||||
const launcher = useRef<HTMLElement | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setPresets(await api.listItemPresets());
|
||||
} catch (e) {
|
||||
toast.error("Could not load item presets", String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void load();
|
||||
}, [active, load]);
|
||||
|
||||
const openEditor = (preset: ItemPreset, trigger: HTMLElement) => {
|
||||
launcher.current = trigger;
|
||||
setTouched(false);
|
||||
setEditing(preset);
|
||||
};
|
||||
|
||||
const set = <K extends keyof ItemPreset>(key: K, value: ItemPreset[K]) =>
|
||||
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
|
||||
const save = async () => {
|
||||
if (!editing) return;
|
||||
setTouched(true);
|
||||
if (!editing.description.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.saveItemPreset({ ...editing, description: editing.description.trim() });
|
||||
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error("Could not save preset", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!deleting || deleting.id === null) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteItemPreset(deleting.id);
|
||||
toast.success("Preset deleted", deleting.description);
|
||||
setDeleting(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error("Could not delete preset", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <InlineLoading description="Loading item presets" />;
|
||||
|
||||
return (
|
||||
<div className="voiced-stack">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p className="voiced-muted">Saved line items you can add to an invoice in one click.</p>
|
||||
<Button
|
||||
kind="tertiary"
|
||||
size="sm"
|
||||
renderIcon={Add}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(blankPreset(), e.currentTarget)}
|
||||
>
|
||||
Add preset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{presets.length === 0 ? (
|
||||
<div className="voiced-empty">No item presets yet.</div>
|
||||
) : (
|
||||
<Table aria-label="Item presets">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Description</TableHeader>
|
||||
<TableHeader>HSN/SAC</TableHeader>
|
||||
<TableHeader>Type</TableHeader>
|
||||
<TableHeader>Amount</TableHeader>
|
||||
<TableHeader>Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{presets.map((p) => (
|
||||
<TableRow key={p.id ?? p.description}>
|
||||
<TableCell>{p.description}</TableCell>
|
||||
<TableCell>{p.hsnSac || "—"}</TableCell>
|
||||
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell>
|
||||
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Edit}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor({ ...p }, e.currentTarget)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={TrashCan}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
||||
launcher.current = e.currentTarget;
|
||||
setDeleting(p);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={editing !== null}
|
||||
modalHeading={editing?.id ? "Edit item preset" : "New item preset"}
|
||||
primaryButtonText={busy ? "Saving…" : "Save preset"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={() => setEditing(null)}
|
||||
onRequestSubmit={() => void save()}
|
||||
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
|
||||
size="sm"
|
||||
>
|
||||
{editing ? (
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextInput
|
||||
id="ip-desc"
|
||||
labelText="Description"
|
||||
value={editing.description}
|
||||
onChange={(e) => set("description", e.target.value)}
|
||||
invalid={touched && !editing.description.trim()}
|
||||
invalidText="Description is required"
|
||||
data-modal-primary-focus
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="ip-hsn"
|
||||
labelText="HSN/SAC (optional)"
|
||||
value={editing.hsnSac}
|
||||
onChange={(e) => set("hsnSac", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<Select
|
||||
id="ip-mode"
|
||||
labelText="Type"
|
||||
value={editing.mode}
|
||||
onChange={(e) => set("mode", e.target.value as LineMode)}
|
||||
>
|
||||
<SelectItem value="fixed" text="Fixed amount" />
|
||||
<SelectItem value="rate" text="Rate × quantity" />
|
||||
</Select>
|
||||
</Column>
|
||||
{editing.mode === "rate" ? (
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<Select
|
||||
id="ip-unit"
|
||||
labelText="Unit"
|
||||
value={editing.unit}
|
||||
onChange={(e) => set("unit", e.target.value as LineUnit)}
|
||||
>
|
||||
{Object.entries(UNIT_LABELS).map(([value, text]) => (
|
||||
<SelectItem key={value} value={value} text={text} />
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
) : null}
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<NumberInput
|
||||
id="ip-rate"
|
||||
label={editing.mode === "rate" ? "Rate (₹)" : "Amount (₹)"}
|
||||
min={0}
|
||||
step={1}
|
||||
value={paiseToRupees(editing.ratePaise)}
|
||||
onChange={(_e, { value }) => set("ratePaise", Math.max(0, rupeesToPaise(Number(value))))}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
danger
|
||||
open={deleting !== null}
|
||||
modalHeading="Delete item preset"
|
||||
primaryButtonText={busy ? "Deleting…" : "Delete"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={() => setDeleting(null)}
|
||||
onRequestSubmit={() => void remove()}
|
||||
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
|
||||
size="xs"
|
||||
>
|
||||
<p>
|
||||
Delete the preset “{deleting?.description}”? Invoices already created from it are not affected.
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { InlineNotification, Modal, Select, SelectItem, TextInput } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import { todayIst } from "../lib/fiscal";
|
||||
import { formatPaise, paiseToInput, parseRupeesToPaise } from "../lib/money";
|
||||
import { PAYMENT_MODES, type Payment, type PaymentMode } from "../lib/payments";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
invoiceId: number;
|
||||
invoiceNumber: string;
|
||||
/** What is still owed, in paise. */
|
||||
balancePaise: number;
|
||||
onClose: () => void;
|
||||
onRecorded: (payment: Payment) => void;
|
||||
}
|
||||
|
||||
/** "Record payment": cash received plus TDS deducted, validated against the balance before it is sent. */
|
||||
export default function RecordPaymentModal({ open, invoiceId, invoiceNumber, balancePaise, onClose, onRecorded }: Props) {
|
||||
const [paidOn, setPaidOn] = useState(todayIst());
|
||||
const [amount, setAmount] = useState("");
|
||||
const [tds, setTds] = useState("");
|
||||
const [mode, setMode] = useState<PaymentMode>("bank_transfer");
|
||||
const [reference, setReference] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
useReturnFocus(open);
|
||||
|
||||
// Each opening starts from the balance, so the common case (paid in full) is one click.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPaidOn(todayIst());
|
||||
setAmount(paiseToInput(balancePaise));
|
||||
setTds("");
|
||||
setMode("bank_transfer");
|
||||
setReference("");
|
||||
setNotes("");
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
}, [open, balancePaise]);
|
||||
|
||||
const amountPaise = parseRupeesToPaise(amount);
|
||||
const tdsPaise = parseRupeesToPaise(tds);
|
||||
const amountInvalid = amountPaise === null;
|
||||
const tdsInvalid = tdsPaise === null;
|
||||
const total = (amountPaise ?? 0) + (tdsPaise ?? 0);
|
||||
const overBalance = !amountInvalid && !tdsInvalid && total > balancePaise;
|
||||
const empty = !amountInvalid && !tdsInvalid && total === 0;
|
||||
const dateInvalid = !/^\d{4}-\d{2}-\d{2}$/.test(paidOn);
|
||||
const canSubmit = !busy && !amountInvalid && !tdsInvalid && !overBalance && !empty && !dateInvalid;
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || amountPaise === null || tdsPaise === null) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payment = await api.recordPayment({ invoiceId, paidOn, amountPaise, tdsPaise, mode, reference, notes });
|
||||
onRecorded(payment);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
modalHeading={`Record payment for ${invoiceNumber}`}
|
||||
primaryButtonText={busy ? "Saving…" : "Record payment"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={!canSubmit}
|
||||
onRequestClose={onClose}
|
||||
onRequestSubmit={() => void submit()}
|
||||
>
|
||||
<div className="voiced-stack">
|
||||
<p>
|
||||
Balance due: <strong>{formatPaise(balancePaise)}</strong>
|
||||
</p>
|
||||
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Could not record the payment" subtitle={error} /> : null}
|
||||
<TextInput
|
||||
id="payDate"
|
||||
type="date"
|
||||
labelText="Date received"
|
||||
value={paidOn}
|
||||
invalid={dateInvalid}
|
||||
invalidText="Enter a date"
|
||||
onChange={(e) => setPaidOn(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
id="payAmount"
|
||||
inputMode="decimal"
|
||||
labelText="Amount received (₹)"
|
||||
helperText="Cash that reached your account"
|
||||
value={amount}
|
||||
invalid={amountInvalid || overBalance || empty}
|
||||
invalidText={
|
||||
amountInvalid
|
||||
? "Enter an amount like 1250.50"
|
||||
: overBalance
|
||||
? `Amount plus TDS is more than the balance of ${formatPaise(balancePaise)}`
|
||||
: "Enter an amount received or TDS deducted"
|
||||
}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
id="payTds"
|
||||
inputMode="decimal"
|
||||
labelText="TDS deducted by client (₹)"
|
||||
helperText="Counts towards settling the invoice; leave empty if none"
|
||||
value={tds}
|
||||
invalid={tdsInvalid}
|
||||
invalidText="Enter an amount like 125.00"
|
||||
onChange={(e) => setTds(e.target.value)}
|
||||
/>
|
||||
<Select id="payMode" labelText="Mode" value={mode} onChange={(e) => setMode(e.target.value as PaymentMode)}>
|
||||
{PAYMENT_MODES.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id} text={m.label} />
|
||||
))}
|
||||
</Select>
|
||||
<TextInput id="payRef" labelText="UTR / cheque number (optional)" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
<TextInput id="payNotes" labelText="Notes (optional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, InlineLoading, Modal, RadioButton, RadioButtonGroup, Tag } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import type { ErpnextPushResult } from "../lib/erpnext";
|
||||
import { describePush, failedPush, summarizeBatch } from "../lib/erpnextUi";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
|
||||
export interface SendTarget {
|
||||
id: number;
|
||||
number: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
invoices: SendTarget[];
|
||||
/** Cancelled or draft invoices that were left out of the selection. */
|
||||
skipped?: number;
|
||||
/** The "submit on push" setting; the user still chooses explicitly. */
|
||||
defaultSubmit: boolean;
|
||||
onClose: () => void;
|
||||
/** Called once when a send has finished, so the list can refresh its statuses. */
|
||||
onFinished: (results: ErpnextPushResult[]) => void;
|
||||
}
|
||||
|
||||
type Phase = "confirm" | "running" | "done";
|
||||
|
||||
const TAG = { success: "green", info: "blue", warning: "warm-gray", error: "red" } as const;
|
||||
|
||||
/** Confirm step (draft or submit), then a sequential send with progress, then per-invoice results. A failure never stops the rest. */
|
||||
export default function SendToErpnextModal({ open, invoices, skipped = 0, defaultSubmit, onClose, onFinished }: Props) {
|
||||
const [submit, setSubmit] = useState(defaultSubmit);
|
||||
const [phase, setPhase] = useState<Phase>("confirm");
|
||||
const [results, setResults] = useState<ErpnextPushResult[]>([]);
|
||||
const alive = useRef(true);
|
||||
useReturnFocus(open);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSubmit(defaultSubmit);
|
||||
setPhase("confirm");
|
||||
setResults([]);
|
||||
}, [open, defaultSubmit]);
|
||||
|
||||
const run = async () => {
|
||||
setPhase("running");
|
||||
const out: ErpnextPushResult[] = [];
|
||||
for (const inv of invoices) {
|
||||
let result: ErpnextPushResult;
|
||||
try {
|
||||
result = await api.erpnextPushInvoice(inv.id, submit);
|
||||
} catch (e) {
|
||||
// Not set up, or the command itself failed: this row fails, the others still go.
|
||||
result = failedPush(inv.id, inv.number, e);
|
||||
}
|
||||
out.push(result);
|
||||
if (alive.current) setResults([...out]);
|
||||
}
|
||||
if (alive.current) setPhase("done");
|
||||
onFinished(out);
|
||||
};
|
||||
|
||||
const count = invoices.length;
|
||||
const noun = count === 1 ? "invoice" : "invoices";
|
||||
|
||||
if (phase === "confirm") {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
modalHeading={count === 1 ? `Send ${invoices[0]?.number} to ERPNext` : `Send ${count} invoices to ERPNext`}
|
||||
primaryButtonText={`Send ${count} ${noun}`}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={count === 0}
|
||||
onRequestClose={onClose}
|
||||
onRequestSubmit={() => void run()}
|
||||
>
|
||||
<div className="voiced-stack">
|
||||
<RadioButtonGroup legendText="What should ERPNext do with it?" name="erp-send-mode" orientation="vertical" valueSelected={submit ? "submit" : "draft"} onChange={(v) => setSubmit(v === "submit")}>
|
||||
<RadioButton id="erp-send-draft" value="draft" labelText="Save as a draft for review" />
|
||||
<RadioButton id="erp-send-submit" value="submit" labelText="Submit it (posts to the books)" />
|
||||
</RadioButtonGroup>
|
||||
<p className="voiced-muted">
|
||||
An invoice that is already in ERPNext is never overwritten or created twice.
|
||||
{skipped > 0 ? ` ${skipped} cancelled ${skipped === 1 ? "invoice is" : "invoices are"} left out: cancelled invoices are never sent.` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = phase === "done" ? summarizeBatch(results) : null;
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
passiveModal
|
||||
modalHeading={phase === "running" ? "Sending to ERPNext" : "Send finished"}
|
||||
preventCloseOnClickOutside
|
||||
onRequestClose={() => {
|
||||
if (phase === "done") onClose();
|
||||
}}
|
||||
>
|
||||
<div className="voiced-stack">
|
||||
{phase === "running" ? (
|
||||
<InlineLoading description={`Sent ${results.length} of ${count}…`} />
|
||||
) : (
|
||||
<p role="status">
|
||||
<strong>{summary?.title}</strong>
|
||||
</p>
|
||||
)}
|
||||
<ul className="voiced-erp__results" aria-label="Results">
|
||||
{results.map((r) => {
|
||||
const note = describePush(r);
|
||||
return (
|
||||
<li key={r.invoiceId}>
|
||||
<Tag type={TAG[note.kind]} size="sm">
|
||||
{r.ok ? (r.noOp ? "Up to date" : r.remoteDocstatus === 1 ? "Submitted" : "Draft") : r.status === "conflict" ? "Conflict" : r.status === "refused" ? "Not sent" : "Error"}
|
||||
</Tag>{" "}
|
||||
<strong>{r.number || `#${r.invoiceId}`}</strong>
|
||||
{r.remoteName && r.ok ? <span className="voiced-muted"> {r.remoteName}</span> : null}
|
||||
{!r.ok && r.error ? <div className="voiced-muted">{r.error}</div> : null}
|
||||
{r.ok && r.warnings.length > 0 ? <div className="voiced-muted">{r.warnings.join(" ")}</div> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{phase === "done" ? (
|
||||
<Button kind="primary" size="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createContext, useContext, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { ToastNotification } from "@carbon/react";
|
||||
import { createToastStore, type ToastStore } from "../lib/toastStore";
|
||||
|
||||
export interface ToastApi {
|
||||
success: (title: string, subtitle?: string) => number;
|
||||
info: (title: string, subtitle?: string) => number;
|
||||
warning: (title: string, subtitle?: string) => number;
|
||||
/** Errors stay until the user closes them. */
|
||||
error: (title: string, subtitle?: string) => number;
|
||||
dismiss: (id: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
function apiFor(store: ToastStore): ToastApi {
|
||||
return {
|
||||
success: (t, s) => store.add("success", t, s),
|
||||
info: (t, s) => store.add("info", t, s),
|
||||
warning: (t, s) => store.add("warning", t, s),
|
||||
error: (t, s) => store.add("error", t, s),
|
||||
dismiss: store.dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stacked top-right toasts. Place it inside the Carbon <Theme> so the tokens apply. */
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const storeRef = useRef<ToastStore | null>(null);
|
||||
if (!storeRef.current) storeRef.current = createToastStore();
|
||||
const store = storeRef.current;
|
||||
const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||
const api = useMemo(() => apiFor(store), [store]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
{/* The live region stays mounted so screen readers announce toasts as they are added. */}
|
||||
<div className="voiced-toasts" aria-live="polite" aria-relevant="additions" role="region" aria-label="Notifications">
|
||||
{toasts.map((t) => (
|
||||
<ToastNotification
|
||||
key={t.id}
|
||||
kind={t.kind}
|
||||
lowContrast
|
||||
title={t.title}
|
||||
subtitle={t.subtitle}
|
||||
role={t.kind === "error" ? "alert" : "status"}
|
||||
onClose={() => {
|
||||
store.dismiss(t.id);
|
||||
return false;
|
||||
}}
|
||||
onCloseButtonClick={() => store.dismiss(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const api = useContext(ToastContext);
|
||||
if (!api) throw new Error("useToast must be used inside <ToastProvider>");
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import { exportInvoicesV1, type InvoiceExportFormat } from "../lib/invoiceExportV1";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
|
||||
/** "Export as voiced.invoice.v1": JSON or line-item CSV of the given invoices, through the save dialog. */
|
||||
export function useGenericExport(): { busy: boolean; run: (ids: number[], format: InvoiceExportFormat) => Promise<void> } {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const run = useCallback(
|
||||
async (ids: number[], format: InvoiceExportFormat) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const done = await exportInvoicesV1(
|
||||
{
|
||||
getInvoice: api.getInvoice,
|
||||
listPayments: api.listPayments,
|
||||
listClients: api.listClients,
|
||||
getLastExportDir: api.getLastExportDir,
|
||||
save: ({ defaultPath, format: fmt }) =>
|
||||
save({ defaultPath, filters: [fmt === "csv" ? { name: "CSV (line items)", extensions: ["csv"] } : { name: "JSON (voiced.invoice.v1)", extensions: ["json"] }] }),
|
||||
writeExportFile: api.writeExportFile,
|
||||
now: () => new Date(),
|
||||
},
|
||||
ids,
|
||||
format,
|
||||
);
|
||||
if (done) toast.success(`Exported ${done.count} ${done.count === 1 ? "invoice" : "invoices"}`, done.path);
|
||||
} catch (e) {
|
||||
toast.error("Could not export the invoices", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
return { busy, run };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Gives focus back to whatever had it when a modal opened, once the modal closes. Carbon's Modal only
|
||||
* does this with a launcher ref, which does not suit modals opened from table rows or menus.
|
||||
*/
|
||||
export function useReturnFocus(open: boolean): void {
|
||||
const origin = useRef<HTMLElement | null>(null);
|
||||
const wasOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (open && !wasOpen.current) {
|
||||
origin.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
} else if (!open && wasOpen.current) {
|
||||
const el = origin.current;
|
||||
origin.current = null;
|
||||
// After the modal has unmounted its focus trap.
|
||||
if (el && el.isConnected) setTimeout(() => el.focus(), 0);
|
||||
}
|
||||
wasOpen.current = open;
|
||||
}, [open]);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
[
|
||||
{
|
||||
"schema": "voiced.invoice.v1",
|
||||
"number": "INV/2026-001",
|
||||
"issue_date": "2026-04-01",
|
||||
"due_date": "2026-05-01",
|
||||
"document_type": "tax_invoice",
|
||||
"status": "issued",
|
||||
"cancelled": false,
|
||||
"cancelled_at": null,
|
||||
"cancel_reason": null,
|
||||
"currency": "INR",
|
||||
"po_number": "PO-9",
|
||||
"supplier": {
|
||||
"name": "Test Vendor",
|
||||
"address": "1 Main Rd\nPune",
|
||||
"gstin": "27AAPFU0939F1ZV",
|
||||
"pan": "AAPFU0939F",
|
||||
"gst_registration": "regular",
|
||||
"state": {
|
||||
"code": "27",
|
||||
"name": "Maharashtra"
|
||||
}
|
||||
},
|
||||
"client": {
|
||||
"name": "Client Ltd",
|
||||
"gstin": "29AABCU9603R1ZJ",
|
||||
"gst_category": "registered_regular",
|
||||
"address": {
|
||||
"text": "12 MG Road\nBengaluru 560001",
|
||||
"line1": "12 MG Road",
|
||||
"line2": null,
|
||||
"city": "Bengaluru",
|
||||
"pincode": "560001",
|
||||
"state": {
|
||||
"code": "29",
|
||||
"name": "Karnataka"
|
||||
}
|
||||
},
|
||||
"place_of_supply": {
|
||||
"code": "29",
|
||||
"name": "Karnataka"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"line": 1,
|
||||
"description": "Design",
|
||||
"hsn_sac": "998391",
|
||||
"quantity": "1",
|
||||
"unit": null,
|
||||
"rate_paise": 500010,
|
||||
"rate": "5000.10",
|
||||
"taxable_value_paise": 500010,
|
||||
"taxable_value": "5000.10"
|
||||
},
|
||||
{
|
||||
"line": 2,
|
||||
"description": "Consulting",
|
||||
"hsn_sac": null,
|
||||
"quantity": "2",
|
||||
"unit": "hour",
|
||||
"rate_paise": 115505,
|
||||
"rate": "1155.05",
|
||||
"taxable_value_paise": 231000,
|
||||
"taxable_value": "2310.00"
|
||||
}
|
||||
],
|
||||
"tax": {
|
||||
"cgst": {
|
||||
"rate": "0",
|
||||
"amount_paise": 0,
|
||||
"amount": "0.00"
|
||||
},
|
||||
"sgst": {
|
||||
"rate": "0",
|
||||
"amount_paise": 0,
|
||||
"amount": "0.00"
|
||||
},
|
||||
"utgst": {
|
||||
"rate": "0",
|
||||
"amount_paise": 0,
|
||||
"amount": "0.00"
|
||||
},
|
||||
"igst": {
|
||||
"rate": "18",
|
||||
"amount_paise": 131400,
|
||||
"amount": "1314.00"
|
||||
}
|
||||
},
|
||||
"totals": {
|
||||
"subtotal_paise": 731010,
|
||||
"subtotal": "7310.10",
|
||||
"discount_paise": 1010,
|
||||
"discount": "10.10",
|
||||
"taxable_value_paise": 730000,
|
||||
"taxable_value": "7300.00",
|
||||
"tax_paise": 131400,
|
||||
"tax": "1314.00",
|
||||
"total_paise": 861400,
|
||||
"total": "8614.00",
|
||||
"amount_in_words": "Rupees Eight Thousand Six Hundred Fourteen Only"
|
||||
},
|
||||
"reverse_charge": false,
|
||||
"notes": "Thank you",
|
||||
"payments": [
|
||||
{
|
||||
"date": "2026-04-10",
|
||||
"amount_paise": 700000,
|
||||
"amount": "7000.00",
|
||||
"tds_paise": 70000,
|
||||
"tds": "700.00",
|
||||
"mode": "bank_transfer",
|
||||
"reference": "UTR123"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -7,9 +7,22 @@ import type {
|
||||
InvoiceInput,
|
||||
InvoiceSeries,
|
||||
InvoiceSummary,
|
||||
ItemPreset,
|
||||
Settings,
|
||||
} from "./types";
|
||||
import type { LogoAsset } from "./logo";
|
||||
import type { Payment, PaymentInput } from "./payments";
|
||||
import type { BackupStatus, BackupSummary, PendingRestore } from "./backup";
|
||||
import type { LedgerRow } from "./historyExport";
|
||||
import type {
|
||||
ErpnextConfig,
|
||||
ErpnextConfigInput,
|
||||
ErpnextConnectionTest,
|
||||
ErpnextOptions,
|
||||
ErpnextPaymentPushResult,
|
||||
ErpnextPushResult,
|
||||
ErpnextSyncStatus,
|
||||
} from "./erpnext";
|
||||
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
||||
|
||||
export interface ArchiveStatus {
|
||||
@@ -36,14 +49,23 @@ export const api = {
|
||||
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
|
||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||
|
||||
listItemPresets: () => invoke<ItemPreset[]>("list_item_presets"),
|
||||
saveItemPreset: (preset: ItemPreset) => invoke<ItemPreset>("save_item_preset", { preset }),
|
||||
deleteItemPreset: (id: number) => invoke<void>("delete_item_preset", { id }),
|
||||
|
||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
||||
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
||||
cancelInvoice: (id: number, reason: string) =>
|
||||
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
||||
listInvoiceLedger: () => invoke<LedgerRow[]>("list_invoice_ledger"),
|
||||
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
||||
|
||||
recordPayment: (input: PaymentInput) => invoke<Payment>("record_payment", { input }),
|
||||
listPayments: (invoiceId: number) => invoke<Payment[]>("list_payments", { invoiceId }),
|
||||
deletePayment: (id: number) => invoke<void>("delete_payment", { id }),
|
||||
|
||||
saveDraft: (id: number | null, payload: unknown) =>
|
||||
invoke<number>("save_draft", { id, payload }),
|
||||
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
|
||||
@@ -92,4 +114,32 @@ export const api = {
|
||||
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
||||
openFile: (path: string) => invoke<void>("open_file", { path }),
|
||||
|
||||
// Backup and restore. A restore is staged now and applied by the next start (see commands/backup.rs).
|
||||
createBackup: (dest: string) => invoke<BackupSummary>("create_backup", { dest }),
|
||||
restoreBackup: (path: string) => invoke<PendingRestore>("restore_backup", { path }),
|
||||
cancelPendingRestore: () => invoke<void>("cancel_pending_restore"),
|
||||
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
|
||||
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
|
||||
restartApp: () => invoke<void>("restart_app"),
|
||||
|
||||
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
|
||||
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
|
||||
erpnextSaveConfig: (config: ErpnextConfigInput) => invoke<ErpnextConfig>("erpnext_save_config", { config }),
|
||||
erpnextTestConnection: (config: ErpnextConfigInput) =>
|
||||
invoke<ErpnextConnectionTest>("erpnext_test_connection", { config }),
|
||||
erpnextLoadOptions: (config: ErpnextConfigInput) => invoke<ErpnextOptions>("erpnext_load_options", { config }),
|
||||
/** `submit` omitted follows the "submit on push" setting. A failed push resolves with `ok: false`. */
|
||||
erpnextPushInvoice: (id: number, submit?: boolean) =>
|
||||
invoke<ErpnextPushResult>("erpnext_push_invoice", { id, submit: submit ?? null }),
|
||||
/** One result per distinct id, in order; a failing row never stops the rest. */
|
||||
erpnextPushInvoices: (ids: number[], submit?: boolean) =>
|
||||
invoke<ErpnextPushResult[]>("erpnext_push_invoices", { ids, submit: submit ?? null }),
|
||||
erpnextPushPayment: (paymentId: number) =>
|
||||
invoke<ErpnextPaymentPushResult>("erpnext_push_payment", { paymentId }),
|
||||
erpnextSyncStatus: (id: number) => invoke<ErpnextSyncStatus>("erpnext_sync_status", { id }),
|
||||
/** Every invoice that has a sync row (invoices never pushed are absent). */
|
||||
erpnextSyncStatuses: () => invoke<ErpnextSyncStatus[]>("erpnext_sync_statuses"),
|
||||
/** `<base>/app/sales-invoice/<name>`; rejects when the invoice was never sent. */
|
||||
erpnextOpenUrl: (id: number) => invoke<string>("erpnext_open_url", { id }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { backupFileName, ensureZipExtension, formatBytes, formatWhen } from "./backup";
|
||||
|
||||
describe("backup helpers", () => {
|
||||
it("names backups by local date and time", () => {
|
||||
expect(backupFileName(new Date(2026, 2, 4, 9, 5))).toBe("voiced-backup-20260304-0905.zip");
|
||||
});
|
||||
it("adds the zip extension only when missing", () => {
|
||||
expect(ensureZipExtension("/a/b")).toBe("/a/b.zip");
|
||||
expect(ensureZipExtension("/a/b.ZIP")).toBe("/a/b.ZIP");
|
||||
expect(ensureZipExtension("/a/b.zip")).toBe("/a/b.zip");
|
||||
});
|
||||
it("formats sizes", () => {
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
expect(formatBytes(1536)).toBe("1.5 KB");
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
|
||||
expect(formatBytes(300 * 1024 * 1024)).toBe("300 MB");
|
||||
});
|
||||
it("falls back to the raw text for an unparseable time", () => {
|
||||
expect(formatWhen("not a date")).toBe("not a date");
|
||||
expect(formatWhen("2026-03-04T10:00:00Z")).toMatch(/2026/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Shapes returned by the backup commands (src-tauri/src/commands/backup.rs). */
|
||||
export interface BackupSummary {
|
||||
path: string;
|
||||
createdAt: string;
|
||||
schemaVersion: number;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface PendingRestore {
|
||||
backupName: string;
|
||||
backupCreatedAt: string;
|
||||
appVersion: string;
|
||||
schemaVersion: number;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
stagedAt: string;
|
||||
}
|
||||
|
||||
export interface LastRestore {
|
||||
ok: boolean;
|
||||
at: string;
|
||||
backupName: string;
|
||||
message: string;
|
||||
safetyDir: string | null;
|
||||
}
|
||||
|
||||
export interface BackupStatus {
|
||||
autoBackup: boolean;
|
||||
autoDir: string;
|
||||
lastAutoBackup: string | null;
|
||||
autoBackupCount: number;
|
||||
pendingRestore: PendingRestore | null;
|
||||
lastRestore: LastRestore | null;
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** `voiced-backup-20260304-1530.zip` in local time. */
|
||||
export function backupFileName(now: Date = new Date()): string {
|
||||
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
||||
return `voiced-backup-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.zip`;
|
||||
}
|
||||
|
||||
/** The save dialog does not always add the extension (notably on Linux). */
|
||||
export function ensureZipExtension(path: string): string {
|
||||
return /\.zip$/i.test(path) ? path : `${path}.zip`;
|
||||
}
|
||||
|
||||
/** 1536 -> "1.5 KB". Sizes of backup contents are display-only, so plain division is fine. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let value = bytes / 1024;
|
||||
let i = 0;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** "4 Mar 2026, 3:30 pm" for an ISO timestamp; the raw text if it does not parse. */
|
||||
export function formatWhen(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { composeAddress, pincodeError, splitLegacyAddress } from "./clientAddress";
|
||||
|
||||
describe("composeAddress", () => {
|
||||
it("joins lines and the city/state/pincode line", () => {
|
||||
expect(
|
||||
composeAddress({ line1: "12 MG Road", line2: "Floor 2", city: "Bengaluru", stateCode: "29", pincode: "560001" }),
|
||||
).toBe("12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
||||
});
|
||||
it("skips empty parts", () => {
|
||||
expect(composeAddress({ line1: " A ", line2: "", city: "", stateCode: "", pincode: "560001" })).toBe("A\n560001");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "Pune", stateCode: "", pincode: "" })).toBe("Pune");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "29", pincode: "" })).toBe("Karnataka");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "", pincode: "" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitLegacyAddress", () => {
|
||||
it("puts the first line in line1 and the rest in line2", () => {
|
||||
expect(splitLegacyAddress("Old style\nfree text\r\nBangalore")).toEqual({
|
||||
line1: "Old style",
|
||||
line2: "free text, Bangalore",
|
||||
});
|
||||
expect(splitLegacyAddress("")).toEqual({ line1: "", line2: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pincodeError", () => {
|
||||
it("accepts empty and 6 digits only", () => {
|
||||
expect(pincodeError("")).toBeUndefined();
|
||||
expect(pincodeError("560001")).toBeUndefined();
|
||||
expect(pincodeError("56001")).toBeDefined();
|
||||
expect(pincodeError("56000A")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { INDIAN_STATES } from "./types";
|
||||
|
||||
export interface AddressParts {
|
||||
line1: string;
|
||||
line2: string;
|
||||
city: string;
|
||||
stateCode: string;
|
||||
pincode: string;
|
||||
}
|
||||
|
||||
export const EMPTY_ADDRESS: AddressParts = { line1: "", line2: "", city: "", stateCode: "", pincode: "" };
|
||||
|
||||
/**
|
||||
* The stored multi-line address: line 1, line 2, then "City, State - Pincode".
|
||||
* Mirrors `compose_address` in src-tauri/src/commands/clients.rs, which is authoritative on save.
|
||||
*/
|
||||
export function composeAddress(parts: AddressParts): string {
|
||||
const stateName = INDIAN_STATES.find((s) => s.code === parts.stateCode)?.name ?? "";
|
||||
const place = [parts.city.trim(), stateName].filter(Boolean).join(", ");
|
||||
const pin = parts.pincode.trim();
|
||||
const last = place && pin ? `${place} - ${pin}` : place || pin;
|
||||
return [parts.line1.trim(), parts.line2.trim(), last].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
/** Best effort for clients saved before structured fields existed: first line to line 1, the rest to line 2. */
|
||||
export function splitLegacyAddress(address: string): Pick<AddressParts, "line1" | "line2"> {
|
||||
const lines = address
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
return { line1: lines[0] ?? "", line2: lines.slice(1).join(", ") };
|
||||
}
|
||||
|
||||
export function isPincode(value: string): boolean {
|
||||
return /^[0-9]{6}$/.test(value.trim());
|
||||
}
|
||||
|
||||
export function pincodeError(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
return isPincode(value) ? undefined : "Pincode is 6 digits";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { invoiceToFormState } from "./duplicate";
|
||||
import { fromDraftPayload, toDraftPayload } from "./draft";
|
||||
import type { Invoice } from "./types";
|
||||
|
||||
const invoice = {
|
||||
id: 7,
|
||||
number: "INV/2025-007",
|
||||
invoiceDate: "2025-04-01",
|
||||
dueDate: "2025-05-01",
|
||||
clientId: 3,
|
||||
clientName: "Acme",
|
||||
clientAddress: "1 Road",
|
||||
clientGstin: "",
|
||||
poNumber: "PO-1",
|
||||
placeOfSupplyStateCode: "29",
|
||||
discount: 50,
|
||||
taxRate: 18,
|
||||
reverseCharge: false,
|
||||
bankAccountId: 2,
|
||||
signaturePath: "assets/sig.png",
|
||||
notes: "Thanks",
|
||||
items: [
|
||||
{ id: 11, description: "Design", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 1000, sortOrder: 4, hsnSac: "998" },
|
||||
],
|
||||
} as unknown as Invoice;
|
||||
|
||||
describe("invoiceToFormState", () => {
|
||||
it("copies the content with a fresh date, due date and no item ids", () => {
|
||||
const s = invoiceToFormState(invoice, { paymentTermsDays: 15 }, "2026-02-20");
|
||||
expect(s.invoiceDate).toBe("2026-02-20");
|
||||
expect(s.dueDate).toBe("2026-03-07");
|
||||
expect(s.clientName).toBe("Acme");
|
||||
expect(s.placeOfSupply).toBe("29");
|
||||
expect(s.bankId).toBe(2);
|
||||
expect(s.saveClient).toBe(false);
|
||||
expect(s.items).toHaveLength(1);
|
||||
expect(s.items[0]).not.toHaveProperty("id");
|
||||
expect(s.items[0].sortOrder).toBe(0);
|
||||
expect(s.items[0].hsnSac).toBe("998");
|
||||
});
|
||||
|
||||
it("survives a round trip through the draft payload", () => {
|
||||
const s = invoiceToFormState(invoice, { paymentTermsDays: 30 }, "2026-02-20");
|
||||
expect(fromDraftPayload(toDraftPayload(s), s)).toEqual(s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { addDays, todayIso } from "./format";
|
||||
import type { InvoiceFormState } from "./draft";
|
||||
import type { Invoice, Settings } from "./types";
|
||||
|
||||
/**
|
||||
* The New invoice form state for "Duplicate": every content field of the issued invoice, with today's date,
|
||||
* a fresh due date, and no link to the original (no id, no number). The result goes through the normal draft
|
||||
* mechanism (`toDraftPayload` + `saveDraft`), so the original stays immutable.
|
||||
*/
|
||||
export function invoiceToFormState(invoice: Invoice, settings: Pick<Settings, "paymentTermsDays">, today = todayIso()): InvoiceFormState {
|
||||
return {
|
||||
invoiceDate: today,
|
||||
dueDate: addDays(today, settings.paymentTermsDays),
|
||||
clientId: invoice.clientId,
|
||||
clientName: invoice.clientName,
|
||||
clientAddress: invoice.clientAddress,
|
||||
clientGstin: invoice.clientGstin,
|
||||
poNumber: invoice.poNumber,
|
||||
placeOfSupply: invoice.placeOfSupplyStateCode,
|
||||
saveClient: false,
|
||||
items: invoice.items.map((item, index) => {
|
||||
const { id: _id, ...rest } = item;
|
||||
return { ...rest, sortOrder: index };
|
||||
}),
|
||||
discount: invoice.discount,
|
||||
taxRate: invoice.taxRate,
|
||||
reverseCharge: invoice.reverseCharge,
|
||||
bankId: invoice.bankAccountId,
|
||||
signaturePath: invoice.signaturePath,
|
||||
notes: invoice.notes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/** Types for the ERPNext commands (see src-tauri/src/commands/erpnext.rs); the UI helpers live in erpnextUi.ts. */
|
||||
|
||||
export type ErpnextNamingMode = "mirror" | "series";
|
||||
|
||||
/** What the backend returns. The API secret is never included: `apiSecretSet` says whether one is stored. */
|
||||
export interface ErpnextConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
apiSecretSet: boolean;
|
||||
extraCaPem: string;
|
||||
company: string;
|
||||
companyAddress: string;
|
||||
namingMode: ErpnextNamingMode;
|
||||
namingSeries: string;
|
||||
incomeAccount: string;
|
||||
costCenter: string;
|
||||
cgstAccount: string;
|
||||
sgstAccount: string;
|
||||
utgstAccount: string;
|
||||
igstAccount: string;
|
||||
taxTemplateIntra: string;
|
||||
taxTemplateInter: string;
|
||||
paymentBankAccount: string;
|
||||
tdsAccount: string;
|
||||
defaultItemCode: string;
|
||||
/** Voiced unit (second, minute, hour, session, unit) to ERPNext UOM. */
|
||||
uomMap: Record<string, string>;
|
||||
customerGroup: string;
|
||||
territory: string;
|
||||
sellingPriceList: string;
|
||||
submitOnPush: boolean;
|
||||
attachPdf: boolean;
|
||||
autoPushOnIssue: boolean;
|
||||
createMissingCustomers: boolean;
|
||||
/** The last connection test, or null if none was stored. */
|
||||
lastDetectResult: ErpnextConnectionTest | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the webview sends to save or test. A blank `apiSecret` keeps the stored secret, a value replaces it,
|
||||
* and `clearSecret` removes it. `lastDetectResult` omitted keeps the stored one.
|
||||
*/
|
||||
export type ErpnextConfigInput = Omit<ErpnextConfig, "apiSecretSet" | "lastDetectResult"> & {
|
||||
apiSecret: string;
|
||||
clearSecret?: boolean;
|
||||
lastDetectResult?: ErpnextConnectionTest | null;
|
||||
};
|
||||
|
||||
export interface ErpnextWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ErpnextVersions {
|
||||
frappe: string | null;
|
||||
erpnext: string | null;
|
||||
indiaCompliance: string | null;
|
||||
}
|
||||
|
||||
export interface ErpnextConnectionTest {
|
||||
user: string;
|
||||
versions: ErpnextVersions;
|
||||
features: { v2Naming: boolean; indiaCompliance: boolean };
|
||||
indiaCompliance: boolean;
|
||||
warnings: ErpnextWarning[];
|
||||
}
|
||||
|
||||
export interface ErpnextOptionItem {
|
||||
/** The value to store (the document name). */
|
||||
name: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ErpnextOptions {
|
||||
companies: ErpnextOptionItem[];
|
||||
companyAddresses: ErpnextOptionItem[];
|
||||
incomeAccounts: ErpnextOptionItem[];
|
||||
taxAccounts: ErpnextOptionItem[];
|
||||
taxTemplates: ErpnextOptionItem[];
|
||||
namingSeries: ErpnextOptionItem[];
|
||||
itemGroups: ErpnextOptionItem[];
|
||||
uoms: ErpnextOptionItem[];
|
||||
costCenters: ErpnextOptionItem[];
|
||||
priceLists: ErpnextOptionItem[];
|
||||
customerGroups: ErpnextOptionItem[];
|
||||
territories: ErpnextOptionItem[];
|
||||
/** Lists that failed to load; the others are still filled. */
|
||||
errors: { list: string; message: string }[];
|
||||
}
|
||||
|
||||
export type ErpnextErrorKind =
|
||||
| "auth"
|
||||
| "permission"
|
||||
| "not_found"
|
||||
| "duplicate"
|
||||
| "validation"
|
||||
| "rate_limit"
|
||||
| "server"
|
||||
| "timeout"
|
||||
| "network"
|
||||
| "config"
|
||||
| "protocol"
|
||||
| "conflict"
|
||||
| "precondition"
|
||||
| "other";
|
||||
|
||||
/** `refused`: nothing was sent and nothing was recorded (a draft or cancelled invoice). */
|
||||
export type ErpnextPushStatus = "synced" | "error" | "conflict" | "refused";
|
||||
|
||||
export interface ErpnextPushResult {
|
||||
invoiceId: number;
|
||||
number: string;
|
||||
ok: boolean;
|
||||
status: ErpnextPushStatus;
|
||||
remoteName: string;
|
||||
/** 0 draft, 1 submitted. */
|
||||
remoteDocstatus: number;
|
||||
/** Created by this call, not found already there. */
|
||||
created: boolean;
|
||||
/** Already synced with the same payload; nothing was done. */
|
||||
noOp: boolean;
|
||||
/** The archived PDF is attached on the remote document. */
|
||||
attached: boolean;
|
||||
error: string | null;
|
||||
errorKind: ErpnextErrorKind | null;
|
||||
/** Non-fatal notes, e.g. sent without the PDF because the invoice is not archived. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ErpnextPaymentPushResult {
|
||||
paymentId: number;
|
||||
invoiceId: number;
|
||||
ok: boolean;
|
||||
entryName: string | null;
|
||||
/** Sent earlier; nothing was posted. */
|
||||
alreadySynced: boolean;
|
||||
error: string | null;
|
||||
errorKind: ErpnextErrorKind | null;
|
||||
}
|
||||
|
||||
/** `none` until something was pushed. */
|
||||
export type ErpnextSyncState = "none" | "pending" | "synced" | "error" | "conflict";
|
||||
|
||||
export interface ErpnextSyncStatus {
|
||||
invoiceId: number;
|
||||
status: ErpnextSyncState;
|
||||
remoteName: string;
|
||||
/** 0 draft, 1 submitted. */
|
||||
remoteDocstatus: number;
|
||||
lastError: string;
|
||||
syncedAt: string | null;
|
||||
attached: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { api } from "./api";
|
||||
|
||||
/** Opens the invoice's ERPNext page in the system browser (`<base>/app/sales-invoice/<name>`); rejects if it was never sent. */
|
||||
export async function openInErpnext(invoiceId: number): Promise<void> {
|
||||
await openUrl(await api.erpnextOpenUrl(invoiceId));
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ErpnextConfig, ErpnextConnectionTest, ErpnextPushResult, ErpnextSyncStatus } from "./erpnext";
|
||||
import {
|
||||
autoPushIssued,
|
||||
baseUrlProblem,
|
||||
connectionProblems,
|
||||
describePaymentPush,
|
||||
describePush,
|
||||
failedPush,
|
||||
hasDetectResult,
|
||||
isConfigured,
|
||||
paymentPushDisabledReason,
|
||||
pushDisabledReason,
|
||||
summarizeBatch,
|
||||
syncTag,
|
||||
toInput,
|
||||
validateConfigForm,
|
||||
warningSeverity,
|
||||
withDefaultUoms,
|
||||
withStoredValue,
|
||||
sortWarnings,
|
||||
sendableSelection,
|
||||
formatSyncedAt,
|
||||
} from "./erpnextUi";
|
||||
|
||||
const test: ErpnextConnectionTest = {
|
||||
user: "api@example.com",
|
||||
versions: { frappe: "15.80.0", erpnext: "15.70.0", indiaCompliance: "15.30.0" },
|
||||
features: { v2Naming: true, indiaCompliance: true },
|
||||
indiaCompliance: true,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const config = (patch: Partial<ErpnextConfig> = {}): ErpnextConfig => ({
|
||||
baseUrl: "https://erp.example.com",
|
||||
apiKey: "key",
|
||||
apiSecretSet: true,
|
||||
extraCaPem: "",
|
||||
company: "Test Co",
|
||||
companyAddress: "",
|
||||
namingMode: "mirror",
|
||||
namingSeries: "",
|
||||
incomeAccount: "",
|
||||
costCenter: "",
|
||||
cgstAccount: "",
|
||||
sgstAccount: "",
|
||||
utgstAccount: "",
|
||||
igstAccount: "",
|
||||
taxTemplateIntra: "",
|
||||
taxTemplateInter: "",
|
||||
paymentBankAccount: "",
|
||||
tdsAccount: "",
|
||||
defaultItemCode: "",
|
||||
uomMap: {},
|
||||
customerGroup: "",
|
||||
territory: "",
|
||||
sellingPriceList: "",
|
||||
submitOnPush: false,
|
||||
attachPdf: true,
|
||||
autoPushOnIssue: false,
|
||||
createMissingCustomers: true,
|
||||
lastDetectResult: test,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const sync = (patch: Partial<ErpnextSyncStatus>): ErpnextSyncStatus => ({
|
||||
invoiceId: 1,
|
||||
status: "synced",
|
||||
remoteName: "INV/2026-001",
|
||||
remoteDocstatus: 0,
|
||||
lastError: "",
|
||||
syncedAt: null,
|
||||
attached: false,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const push = (patch: Partial<ErpnextPushResult> = {}): ErpnextPushResult => ({
|
||||
invoiceId: 1,
|
||||
number: "INV/2026-001",
|
||||
ok: true,
|
||||
status: "synced",
|
||||
remoteName: "INV/2026-001",
|
||||
remoteDocstatus: 0,
|
||||
created: true,
|
||||
noOp: false,
|
||||
attached: true,
|
||||
error: null,
|
||||
errorKind: null,
|
||||
warnings: [],
|
||||
...patch,
|
||||
});
|
||||
|
||||
describe("status tags", () => {
|
||||
it("maps every sync state", () => {
|
||||
expect(syncTag(undefined)).toMatchObject({ label: "Not sent", type: "gray" });
|
||||
expect(syncTag(sync({ status: "none" })).label).toBe("Not sent");
|
||||
expect(syncTag(sync({ remoteDocstatus: 0 }))).toMatchObject({ label: "Draft in ERPNext", type: "blue" });
|
||||
expect(syncTag(sync({ remoteDocstatus: 1 }))).toMatchObject({ label: "Submitted", type: "green" });
|
||||
expect(syncTag(sync({ status: "error", lastError: "Item missing" }))).toMatchObject({ label: "Error", type: "red", tooltip: "Item missing" });
|
||||
expect(syncTag(sync({ status: "conflict", lastError: "" }))).toMatchObject({ label: "Conflict", type: "purple" });
|
||||
expect(syncTag(sync({ status: "error", lastError: "" })).tooltip).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("configured and disabled reasons", () => {
|
||||
it("needs an address, a key and a stored secret", () => {
|
||||
expect(isConfigured(config())).toBe(true);
|
||||
expect(isConfigured(config({ baseUrl: " " }))).toBe(false);
|
||||
expect(isConfigured(config({ apiKey: "" }))).toBe(false);
|
||||
expect(isConfigured(config({ apiSecretSet: false }))).toBe(false);
|
||||
expect(isConfigured(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for a connection test only when the vendor is registered", () => {
|
||||
const untested = config({ lastDetectResult: null });
|
||||
expect(pushDisabledReason({ cfg: untested, vendorRegistered: true })).toMatch(/Run Test connection first/);
|
||||
expect(pushDisabledReason({ cfg: untested, vendorRegistered: false })).toBeNull();
|
||||
expect(pushDisabledReason({ cfg: config(), vendorRegistered: true })).toBeNull();
|
||||
expect(hasDetectResult(null)).toBe(false);
|
||||
expect(hasDetectResult({} as ErpnextConnectionTest)).toBe(false);
|
||||
expect(pushDisabledReason({ cfg: null, vendorRegistered: false })).toMatch(/not set up/);
|
||||
expect(pushDisabledReason({ cfg: config(), vendorRegistered: false, invoiceStatus: "cancelled" })).toMatch(/Cancelled/);
|
||||
expect(pushDisabledReason({ cfg: config(), vendorRegistered: false, invoiceStatus: "issued" })).toBeNull();
|
||||
});
|
||||
|
||||
it("allows a payment only against a submitted invoice, once", () => {
|
||||
const cfg = config();
|
||||
expect(paymentPushDisabledReason({ cfg, sync: undefined, entry: null })).toMatch(/Send the invoice/);
|
||||
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 0 }), entry: null })).toMatch(/Submit the invoice in ERPNext first/);
|
||||
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 1 }), entry: null })).toBeNull();
|
||||
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 1 }), entry: "ACC-PAY-1" })).toMatch(/ACC-PAY-1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("push results", () => {
|
||||
it("describes outcomes", () => {
|
||||
expect(describePush(push())).toMatchObject({ kind: "success", subtitle: "INV/2026-001 (draft)" });
|
||||
expect(describePush(push({ remoteDocstatus: 1 })).subtitle).toContain("submitted");
|
||||
expect(describePush(push({ noOp: true })).kind).toBe("info");
|
||||
const warned = describePush(push({ warnings: ["The PDF was not attached: no permission"] }));
|
||||
expect(warned.kind).toBe("warning");
|
||||
expect(warned.subtitle).toContain("no permission");
|
||||
expect(describePush(push({ ok: false, status: "refused", error: "Run Test connection first", errorKind: "precondition" })).kind).toBe("warning");
|
||||
expect(describePush(push({ ok: false, status: "conflict", error: "different total" })).title).toMatch(/conflicts/);
|
||||
expect(describePush(push({ ok: false, status: "error", error: "417", errorKind: "validation" })).kind).toBe("error");
|
||||
expect(describePaymentPush({ paymentId: 1, invoiceId: 1, ok: true, entryName: "PE-1", alreadySynced: false, error: null, errorKind: null }).kind).toBe("success");
|
||||
expect(describePaymentPush({ paymentId: 1, invoiceId: 1, ok: false, entryName: null, alreadySynced: false, error: "nope", errorKind: "other" }).kind).toBe("error");
|
||||
});
|
||||
|
||||
it("turns a rejected call into a failed row", () => {
|
||||
const r = failedPush(3, "INV/3", "ERPNext is not set up yet");
|
||||
expect(r).toMatchObject({ ok: false, invoiceId: 3, number: "INV/3", error: "ERPNext is not set up yet" });
|
||||
expect(failedPush(3, "INV/3", new Error("boom")).error).toBe("boom");
|
||||
});
|
||||
|
||||
it("summarises a batch", () => {
|
||||
const s = summarizeBatch([push(), push({ noOp: true }), push({ ok: false, status: "error", error: "x" })]);
|
||||
expect(s).toMatchObject({ total: 3, sent: 1, upToDate: 1, failed: 1, kind: "warning" });
|
||||
expect(s.title).toBe("1 sent, 1 already up to date, 1 failed");
|
||||
expect(summarizeBatch([push(), push()]).kind).toBe("success");
|
||||
expect(summarizeBatch([push({ ok: false })]).kind).toBe("error");
|
||||
expect(summarizeBatch([push({ warnings: ["w"] })]).kind).toBe("warning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto push on issue", () => {
|
||||
const invoice = { id: 7, number: "INV/2026-007" };
|
||||
|
||||
it("does nothing when it is off or ERPNext is not set up", async () => {
|
||||
const pushFn = vi.fn();
|
||||
expect(await autoPushIssued({ getConfig: async () => config(), push: pushFn }, invoice, true)).toBeNull();
|
||||
expect(await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true, apiSecretSet: false }), push: pushFn }, invoice, true)).toBeNull();
|
||||
expect(await autoPushIssued({ getConfig: async () => Promise.reject(new Error("db")), push: pushFn }, invoice, true)).toBeNull();
|
||||
expect(pushFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pushes with the configured submit setting and reports the result", async () => {
|
||||
const pushFn = vi.fn(async () => push({ number: invoice.number }));
|
||||
const notice = await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true }), push: pushFn }, invoice, true);
|
||||
expect(pushFn).toHaveBeenCalledWith(7);
|
||||
expect(notice?.kind).toBe("success");
|
||||
});
|
||||
|
||||
it("never throws: a rejected push becomes an error notice", async () => {
|
||||
const notice = await autoPushIssued(
|
||||
{ getConfig: async () => config({ autoPushOnIssue: true }), push: async () => Promise.reject("ERPNext is not set up yet") },
|
||||
invoice,
|
||||
false,
|
||||
);
|
||||
expect(notice).toMatchObject({ kind: "error" });
|
||||
expect(notice?.subtitle).toContain("not set up");
|
||||
});
|
||||
|
||||
it("explains a missing connection test instead of pushing", async () => {
|
||||
const pushFn = vi.fn();
|
||||
const notice = await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true, lastDetectResult: null }), push: pushFn }, invoice, true);
|
||||
expect(pushFn).not.toHaveBeenCalled();
|
||||
expect(notice?.subtitle).toMatch(/Run Test connection first/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnings", () => {
|
||||
it("ranks errors before warnings before notes", () => {
|
||||
expect(warningSeverity("gst_accounts_not_configured")).toBe("error");
|
||||
expect(warningSeverity("ic_not_detected")).toBe("warning");
|
||||
expect(warningSeverity("versions_unknown")).toBe("info");
|
||||
const sorted = sortWarnings([
|
||||
{ code: "versions_unknown", message: "a" },
|
||||
{ code: "company_missing", message: "b" },
|
||||
{ code: "ic_not_detected", message: "c" },
|
||||
]);
|
||||
expect(sorted.map((w) => w.code)).toEqual(["company_missing", "ic_not_detected", "versions_unknown"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings form", () => {
|
||||
it("validates the address like the backend", () => {
|
||||
expect(baseUrlProblem("")).toMatch(/Enter/);
|
||||
expect(baseUrlProblem("https://erp.example.com")).toBeNull();
|
||||
expect(baseUrlProblem("erp.example.com")).toBeNull();
|
||||
expect(baseUrlProblem("http://erp.example.com")).toMatch(/https/);
|
||||
expect(baseUrlProblem("http://localhost:8000")).toBeNull();
|
||||
expect(baseUrlProblem("localhost:8000")).toBeNull();
|
||||
expect(baseUrlProblem("http://site.test")).toBeNull();
|
||||
expect(baseUrlProblem("http://127.0.0.1:8000")).toBeNull();
|
||||
expect(baseUrlProblem("https://user:pw@erp.example.com")).toMatch(/credentials/);
|
||||
expect(baseUrlProblem("ftp://erp.example.com")).toMatch(/https/);
|
||||
});
|
||||
|
||||
it("builds the input without write-only fields and fills the UOM defaults", () => {
|
||||
const input = toInput(config({ uomMap: { hour: "Hr" } }));
|
||||
expect(input).not.toHaveProperty("apiSecretSet");
|
||||
expect(input).not.toHaveProperty("lastDetectResult");
|
||||
expect(input.apiSecret).toBe("");
|
||||
expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" });
|
||||
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
|
||||
});
|
||||
|
||||
it("needs a secret only when none is stored or it is being cleared", () => {
|
||||
const form = toInput(config());
|
||||
expect(connectionProblems(form, true)).toEqual({});
|
||||
expect(connectionProblems(form, false).apiSecret).toBeTruthy();
|
||||
expect(connectionProblems({ ...form, clearSecret: true }, true).apiSecret).toBeTruthy();
|
||||
expect(connectionProblems({ ...form, clearSecret: true, apiSecret: "new" }, true)).toEqual({});
|
||||
expect(connectionProblems({ ...form, apiKey: " " }, true).apiKey).toBeTruthy();
|
||||
});
|
||||
|
||||
it("validates a save: untouched is fine, series mode needs a series, mirror needs v2 naming", () => {
|
||||
const empty = toInput(config({ baseUrl: "", apiKey: "", apiSecretSet: false }));
|
||||
expect(validateConfigForm(empty, false, null)).toEqual({});
|
||||
const form = toInput(config());
|
||||
expect(validateConfigForm(form, true, test)).toEqual({});
|
||||
expect(validateConfigForm({ ...form, namingMode: "series", namingSeries: "" }, true, test).namingSeries).toBeTruthy();
|
||||
expect(validateConfigForm({ ...form, namingMode: "series", namingSeries: "SINV-.YY.-" }, true, test)).toEqual({});
|
||||
const old = { ...test, features: { v2Naming: false, indiaCompliance: true } };
|
||||
expect(validateConfigForm(form, true, old).namingMode).toBeTruthy();
|
||||
expect(validateConfigForm(form, true, null).namingMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a saved value visible when the loaded list lacks it", () => {
|
||||
const items = [{ name: "A", label: "A", detail: "" }];
|
||||
expect(withStoredValue(items, "A")).toBe(items);
|
||||
expect(withStoredValue(items, "")).toBe(items);
|
||||
const out = withStoredValue(items, "Sales - AC");
|
||||
expect(out[0]).toMatchObject({ name: "Sales - AC" });
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection and dates", () => {
|
||||
it("leaves cancelled invoices out of a send", () => {
|
||||
const rows = [
|
||||
{ id: 1, status: "issued" },
|
||||
{ id: 2, status: "cancelled" },
|
||||
{ id: 3, status: "issued" },
|
||||
];
|
||||
expect(sendableSelection(rows)).toEqual({ send: [rows[0], rows[2]], skipped: 1 });
|
||||
expect(sendableSelection([])).toEqual({ send: [], skipped: 0 });
|
||||
});
|
||||
|
||||
it("formats the sync time defensively", () => {
|
||||
expect(formatSyncedAt(null)).toBe("");
|
||||
expect(formatSyncedAt("not a date")).toBe("not a date");
|
||||
expect(formatSyncedAt("2026-04-01T10:30:00+00:00")).toMatch(/2026/);
|
||||
});
|
||||
});
|
||||