Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1031b3ecc | ||
|
|
4960e26d8c | ||
|
|
3cb0ac9236 | ||
|
|
9bdb142090 | ||
|
|
c78bf1a851 | ||
|
|
c3e2e05e89 | ||
|
|
e786580d42 | ||
|
|
6efb498409 | ||
|
|
ae80922d03 | ||
|
|
23dd283a42 | ||
|
|
8dc4d85e14 | ||
|
|
2ffbf0f66c | ||
|
|
d3b5fbb1b8 | ||
|
|
86b4d436fa | ||
|
|
92f952b136 | ||
|
|
1623b879e2 | ||
|
|
3510cc4260 |
@@ -0,0 +1,21 @@
|
|||||||
|
# Contributing to Voiced
|
||||||
|
|
||||||
|
Contributions are welcome: bug reports, fixes, templates, translations and documentation.
|
||||||
|
|
||||||
|
1. Open an issue first for anything larger than a small fix, so the work is not duplicated.
|
||||||
|
2. Branch from `main`. Keep a change focused on one thing.
|
||||||
|
3. Run the gates from `docs/RELEASE.md` that your change touches: `npm run build`, `npm test`, `npm run fonts:verify`
|
||||||
|
and `cargo test --manifest-path src-tauri/Cargo.toml`. Changes to the PDF engine or a template also need the
|
||||||
|
real-webview self-test (see the README, "Tests and gates").
|
||||||
|
4. Do not add personal data, real GSTINs, bank details or client names to code, tests, fixtures or screenshots. Use
|
||||||
|
synthetic values.
|
||||||
|
5. Open a pull request describing what changed and why.
|
||||||
|
|
||||||
|
By submitting a contribution you agree that it is licensed under the Apache License 2.0 (see `LICENSE`), the same
|
||||||
|
licence as the project, as described in section 5 of that licence.
|
||||||
|
|
||||||
|
## Using Voiced in your own project
|
||||||
|
|
||||||
|
You may fork, modify and redistribute Voiced under the Apache License 2.0. You must keep `LICENSE` and `NOTICE`,
|
||||||
|
keep the copyright notices, and mark files you changed. Please also credit the original: "Based on Voiced by
|
||||||
|
Bongbetic".
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Voiced
|
||||||
|
Copyright 2026 Bongbetic
|
||||||
|
|
||||||
|
This product includes software developed by Bongbetic (https://git.bongbetic.com/xavierk/Voiced).
|
||||||
|
|
||||||
|
If you redistribute Voiced, or a work derived from it (including a fork), the Apache License 2.0 requires you to keep
|
||||||
|
this NOTICE file, the LICENSE file and the copyright notices in the source files, and to state any changes you made.
|
||||||
|
Please also keep the credit "Based on Voiced by Bongbetic" in your README and in your application's About or
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
Bundled fonts in public/fonts/ are covered by their own SIL Open Font License 1.1 texts (OFL.txt in each folder).
|
||||||
@@ -1,84 +1,202 @@
|
|||||||
# Voiced
|
# Voiced
|
||||||
|
|
||||||
An offline-first desktop invoicing app for **Test Vendor**, built by **Bongbetic**.
|
An offline-first GST invoicing desktop app built by **Bongbetic**.
|
||||||
|
|
||||||
Voiced turns a few details into a clean, GST-ready PDF invoice. It keeps a running
|
Voiced turns a few details into a clean, print-ready PDF invoice. It keeps a running invoice-number series in a
|
||||||
invoice-number series in a local database, remembers your vendor and bank details, and
|
local database, remembers your vendor, client and bank details, and works without an account or a network
|
||||||
exports print-ready PDFs — no account, no network required.
|
connection. The only optional network feature is the ERPNext push, which you configure yourself.
|
||||||
|
|
||||||
|
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
|
||||||
|
|
||||||
|
Current release: **1.0.2**. Installers and the Void package are attached to the release on the project's Gitea
|
||||||
|
page (see "Installing").
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Automatic invoice numbering** — a persistent series (e.g. `INV/2026-001`) that keeps
|
- **First-run setup.** A four-step wizard (business, branding, bank, numbering) starts blank: no vendor, bank or tax
|
||||||
counting up, with a one-click "start a new series" that resets the counter.
|
details are pre-filled, and a business name and state are required. **Settings, Data, Run setup again** reopens it
|
||||||
- **Guided first-run setup** — your name, address, contact, PAN, GSTIN, logo, signature and
|
with your current details; invoices, clients and backups are kept, and numbering carries on unless you change the
|
||||||
bank details, pre-filled from your existing invoice and editable at any time.
|
series prefix or digits.
|
||||||
- **Two line-item modes** — a fixed amount, or a rate × quantity where the unit can be per
|
- **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series,
|
||||||
second / minute / hour / session / unit (e.g. 10 sessions × ₹2,500).
|
drafts, issue and cancel. An issued invoice is immutable: it stores a snapshot of the vendor, client, GST figures
|
||||||
- **GST support** — toggle taxes on/off and split as **CGST + SGST** (within state) or
|
and layout. Line items are a fixed amount or rate x quantity (per second, minute, hour, session or unit), with
|
||||||
**IGST** (inter-state), with live totals and Indian amount-in-words.
|
item presets.
|
||||||
- **Signatures** — attach a PNG/JPEG per invoice, or omit it and the invoice notes that it is
|
- **GST.** All money and tax maths is done in integer paise. CGST+SGST, IGST or UTGST follow from the supplier's
|
||||||
computer-generated and needs no signature.
|
state and the place of supply, and a mismatch is rejected. An unregistered vendor gets a plain "Invoice" with no
|
||||||
- **PDF export** — a print-ready A4 invoice styled to match the original `format.pdf`.
|
tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words.
|
||||||
- **Saved clients**, invoice history with re-export, and light/dark (Carbon g10/g100) themes.
|
- **Templates.** 14 templates: Voiced Standard (Classic) and 13 further designs (Linea,
|
||||||
- **Input validation** for PAN, IFSC, GSTIN, email and phone.
|
Monogram, Serenity, Crimson Grid, Tangerine Ledger, Slate Band, Teal Swoosh, Purple Pop, Citrus Split, Monolith,
|
||||||
|
Cobalt Stripe, Marble, Highlighter). Your logo fills the template's logo slot; a few templates offer an opt-in
|
||||||
|
logo position. Specs are in `docs/templates/`.
|
||||||
|
- **Page setup.** A4 or Letter, and margin presets (template, narrow 36 pt, normal 54 pt).
|
||||||
|
- **Export modes.** *Searchable* PDF (text layer kept) or *flattened* (pages rasterised at 300 DPI and rebuilt as
|
||||||
|
an image-only PDF). Issued PDFs are archived by content hash, and "Original as issued" re-exports the exact
|
||||||
|
archived bytes.
|
||||||
|
- **Fonts.** 11 bundled font families (see "Fonts and licences"). The three commercial template faces (Now, Gotham,
|
||||||
|
Open Sauce One) can be overridden with your own licensed files under Settings, Fonts.
|
||||||
|
- **Clients and presets.** Saved clients with structured addresses, state and GST category; item presets.
|
||||||
|
- **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding
|
||||||
|
balance is cash plus TDS against the invoice total.
|
||||||
|
- **History.** Filterable invoice list, detail view with payments, and CSV or JSON export of the list.
|
||||||
|
- **Backup and restore.** Manual and automatic daily backups of the database, assets, PDF archive and imported fonts.
|
||||||
|
- **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices
|
||||||
|
(see `docs/voiced-invoice-v1.md`).
|
||||||
|
- **ERPNext integration.** Push an invoice (as draft or submitted) with its PDF attached, and record payments, to an
|
||||||
|
ERPNext v15 site.
|
||||||
|
- Light and dark (Carbon g10/g100) themes.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
| Layer | Choice |
|
| Layer | Choice |
|
||||||
| --------- | ------------------------------------------------------------------- |
|
| ------- | ------------------------------------------------------------------------------------------- |
|
||||||
| Shell | [Tauri v2](https://v2.tauri.app/) (Rust core, WebView2 on Windows) |
|
| Shell | [Tauri v2](https://v2.tauri.app/) (Rust core, WebKitGTK on Linux, WebView2 on Windows) |
|
||||||
| UI | React + TypeScript + [IBM Carbon Design System](https://carbondesignsystem.com/) (`@carbon/react`) |
|
| UI | React 19 + TypeScript + [IBM Carbon](https://carbondesignsystem.com/) (`@carbon/react`) |
|
||||||
| Fonts | IBM Plex, self-hosted (works offline) |
|
| Storage | SQLite via `rusqlite` (bundled), numbered migrations |
|
||||||
| Storage | SQLite via `rusqlite` (bundled) |
|
| PDF | `@react-pdf/renderer` 4.9.0 in a Web Worker, pdf.js for preview and flattening |
|
||||||
| PDF | `@react-pdf/renderer` with bundled IBM Plex |
|
| ERPNext | `reqwest` with rustls (ring provider); all HTTP runs in Rust |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js 20+** and npm
|
- **Node.js 22** and npm
|
||||||
- **Rust** (stable) via [rustup](https://rustup.rs/)
|
- **Rust** stable, via [rustup](https://rustup.rs/)
|
||||||
- Linux dev only: WebKitGTK 4.1, libsoup3, GTK3, librsvg, `pkg-config`, `clang`/`llvm`/`lld`
|
- Linux only: WebKitGTK 4.1, libsoup3, GTK3, librsvg, `pkg-config`, `clang`/`llvm`/`lld`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run app:dev # launches the desktop app with hot reload
|
npm run app:dev # tauri dev, served from localhost:1420 (does not exercise the tauri:// protocol)
|
||||||
```
|
```
|
||||||
|
|
||||||
Other scripts:
|
| Command | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run build` | `tsc --noEmit` and `vite build` (the type-check gate) |
|
||||||
|
| `npm test` | `vitest run`, about 7 minutes; run on a quiet machine because load starves the sweep tests |
|
||||||
|
| `npm run fonts:verify` | Checks the bundled fonts against `public/fonts/manifest.json` (`fonts:manifest` regenerates it) |
|
||||||
|
| `npm run logo:measure` | Regenerates `src/pdf/templates/slots.generated.ts` from the reference template PDFs (not in the repo); must be idempotent |
|
||||||
|
| `npm run thumbnails` | Regenerates `public/templates/*.png` from the synthetic sample invoice (neutral vendor and bank, generated wordmark) |
|
||||||
|
| `node scripts/logo/make-sample-logo.mjs` | Regenerates the synthetic sample logo in `src/pdf/testing/fixtures/` |
|
||||||
|
| `npm run templates:compare` | Reference vs Voiced side-by-side PNGs under `/tmp` (needs the reference PDFs, not in the repo) |
|
||||||
|
| `cargo test --manifest-path src-tauri/Cargo.toml` | Rust unit tests |
|
||||||
|
| `npm run app:build` | Production build for the current platform (custom-protocol) |
|
||||||
|
|
||||||
```bash
|
The release procedure, including the manual first-run checklist, is in `docs/RELEASE.md`.
|
||||||
npm run build # type-check + build the web frontend
|
|
||||||
npm run app:build # package the app for the current platform
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building for Windows (cross-compile from Linux/macOS)
|
## Installing
|
||||||
|
|
||||||
Tauri only supports the MSVC target. Cross-compiling uses [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin)
|
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
|
||||||
and produces an **NSIS installer** plus a portable `.exe`. A `.msi` still requires a real
|
`v1.0.2`).
|
||||||
Windows host.
|
|
||||||
|
- **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
|
```bash
|
||||||
# one-time setup
|
# one-time setup
|
||||||
rustup target add x86_64-pc-windows-msvc
|
rustup target add x86_64-pc-windows-msvc
|
||||||
cargo install --locked cargo-xwin
|
cargo install --locked cargo-xwin
|
||||||
# plus NSIS (makensis), LLVM/LLD and clang on the host
|
# plus makensis (NSIS), LLVM/LLD and clang on the host
|
||||||
|
|
||||||
npm run app:build:windows
|
npm run app:build:windows # embeds the small WebView2 bootstrapper (needs internet if WebView2 is missing)
|
||||||
|
npm run app:build:windows:offline # embeds the full WebView2 runtime (over 100 MB larger), for offline machines
|
||||||
```
|
```
|
||||||
|
|
||||||
Output lands in `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/` and the
|
Output: `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/Voiced_<version>_x64-setup.exe`, and the portable
|
||||||
portable binary in the sibling `release/` folder.
|
binary in the sibling `release/` folder. The installer is a per-user install (`installMode: currentUser`). The
|
||||||
|
frontend, fonts and pdf.js are compiled into the executable. Size budget: about 25 MB for the standard installer.
|
||||||
|
`cargo tree -i aws-lc-sys --target x86_64-pc-windows-msvc` must print nothing (the TLS stack is rustls with the ring
|
||||||
|
provider only, which cross-compiles cleanly).
|
||||||
|
|
||||||
### Notes for Linux hosts without an `nsis` package
|
Hosts without an `nsis` package (for example Void Linux):
|
||||||
|
|
||||||
`makensis` is required for the installer, and Tauri also fetches a small NSIS plugin. On
|
1. Provide `makensis` and put it on `PATH`.
|
||||||
distributions that don't package NSIS (e.g. Void Linux):
|
2. Point it at a full NSIS data directory (3.11 recommended, for `Win/RestartManager.nsh`) with `NSISDIR`.
|
||||||
|
|
||||||
1. Provide `makensis` (e.g. extract it from your distribution's or an upstream package) and
|
|
||||||
put it on `PATH`.
|
|
||||||
2. Point `makensis` at a full NSIS data directory (3.11 recommended, for `Win/RestartManager.nsh`);
|
|
||||||
it looks in the directory compiled into the binary unless `NSISDIR` is set.
|
|
||||||
3. Pre-seed Tauri's plugin cache so it does not have to download it:
|
3. Pre-seed Tauri's plugin cache so it does not have to download it:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -87,13 +205,11 @@ distributions that don't package NSIS (e.g. Void Linux):
|
|||||||
# from https://github.com/tauri-apps/nsis-tauri-utils/releases
|
# from https://github.com/tauri-apps/nsis-tauri-utils/releases
|
||||||
```
|
```
|
||||||
|
|
||||||
With those in place, `npm run app:build:windows` produces the NSIS `-setup.exe` and the
|
The installer has been installed and run once in a Windows 11 virtual machine; it has not been tried on physical
|
||||||
portable `voiced.exe`.
|
hardware.
|
||||||
|
|
||||||
## Installing on Void Linux
|
## Installing on Void Linux
|
||||||
|
|
||||||
Two ways to get a native `.xbps` package.
|
|
||||||
|
|
||||||
### Prebuilt binary package (no xbps-src needed)
|
### Prebuilt binary package (no xbps-src needed)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -102,52 +218,97 @@ echo "repository=$(pwd)/build/void/repo" | sudo tee /etc/xbps.d/99-voiced-local.
|
|||||||
sudo xbps-install -Sy voiced
|
sudo xbps-install -Sy voiced
|
||||||
```
|
```
|
||||||
|
|
||||||
Launch **Voiced** from the application menu, or run `voiced`. Remove it with
|
The script runs `npx tauri build --no-bundle --features custom-protocol` (using your existing `node_modules`), stages
|
||||||
`sudo xbps-remove voiced`.
|
the install tree and runs `xbps-create` and `xbps-rindex`. Launch **Voiced** from the application menu or run
|
||||||
|
`voiced`; remove it with `sudo xbps-remove voiced`.
|
||||||
|
|
||||||
The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on
|
The package installs `/usr/bin/voiced`, a desktop entry, hicolor icons, the bundled fonts under
|
||||||
`libwebkit2gtk41`.
|
`/usr/share/fonts/voiced/<family>/`, the OFL texts under `/usr/share/licenses/voiced/`, and `INSTALL` and `REMOVE`
|
||||||
|
action scripts that run `fc-cache -f /usr/share/fonts/voiced` after install and removal. The `voiced/` font
|
||||||
**Fonts on Void.** The package also installs the bundled TTF fonts under
|
subdirectory avoids file conflicts with Void's `font-ibm-plex-ttf` and `noto-fonts-ttf`. It depends on
|
||||||
`/usr/share/fonts/voiced/<family>/` and each family's OFL licence under
|
`libwebkit2gtk41>=2.50`, `fontconfig`, `xdg-utils`, `hicolor-icon-theme` and `desktop-file-utils`.
|
||||||
`/usr/share/licenses/voiced/`. The `voiced/` subdirectory avoids file conflicts with Void's own
|
|
||||||
`font-ibm-plex-ttf` and `noto-fonts-ttf`. The `INSTALL` and `REMOVE` scripts in `packaging/void/`
|
|
||||||
run `fc-cache -f /usr/share/fonts/voiced` after install and removal. `xbps-create` packs them from
|
|
||||||
the destdir root (checked with xbps 0.60.7).
|
|
||||||
|
|
||||||
### Building through xbps-src
|
### Building through xbps-src
|
||||||
|
|
||||||
Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p void-packages/srcpkgs/voiced
|
mkdir -p void-packages/srcpkgs/voiced
|
||||||
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
|
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
|
||||||
cd void-packages && ./xbps-src pkg voiced
|
cd void-packages && ./xbps-src pkg voiced
|
||||||
```
|
```
|
||||||
|
|
||||||
See the comments at the top of the template about the private source archive and checksums.
|
`INSTALL` and `REMOVE` must sit next to the template; xbps-src packs them. The source is a tagged archive of the
|
||||||
|
repository (see the comments at the top of the template; `checksum=SKIP` until a tarball is published).
|
||||||
## Where data lives
|
xbps-src builds in a network-less chroot, so vendor the dependencies first: run `cargo vendor` in `src-tauri/` (with a
|
||||||
|
`.cargo/config.toml` pointing at `vendor/`) and populate the npm cache, and include the results in the source
|
||||||
| What | Location |
|
archive. The template runs `npm ci` (not `--omit=optional`: that removes
|
||||||
| ------------------- | ------------------------------------------------------------- |
|
the native rollup and esbuild binaries the Vite build needs, and `@napi-rs/canvas` is type-checked by `tsc`).
|
||||||
| Database | `<app-data>/voiced.db` — on Windows `%APPDATA%\com.bongbetic.voiced\` |
|
No OpenSSL is needed (rustls), and the
|
||||||
| Logos & signatures | `<app-data>/assets/` |
|
dependencies of the ERPNext and backup code (`reqwest`, `zip`) are pure Rust.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/ React + Carbon frontend
|
src/ React + Carbon frontend
|
||||||
views/ Onboarding · NewInvoice · InvoiceHistory · SeriesSettings · AppSettings
|
views/ Onboarding, NewInvoice, InvoiceHistory, InvoiceDetail, Clients, AppSettings, SeriesSettings
|
||||||
components/ AppShell · InvoicePreview · ImagePicker
|
components/ AppShell, TemplatePicker, PageSetupControls, PdfPreview, LogoBranding,
|
||||||
pdf/ InvoiceDocument (react-pdf template)
|
FontOverrideTable, DataBackupPanel, ErpnextSettingsPanel, RecordPaymentModal, ...
|
||||||
lib/ api · types · invoice · numberToWords · validators · format · pdf
|
hooks/ Export, PDF preview, user fonts, shortcuts
|
||||||
src-tauri/ Rust backend (Tauri commands, SQLite schema, asset store)
|
lib/ Typed Tauri wrapper (api.ts), money, GST helpers, exportFlow, historyExport,
|
||||||
public/brand/ Bongbetic + vendor brand assets
|
invoiceExportV1, ERPNext UI helpers
|
||||||
public/fonts/ IBM Plex (woff/woff2)
|
pdf/ PDF engine
|
||||||
packaging/void/ Void Linux packaging (xbps-src template + build script + desktop entry)
|
model/ buildRenderModel (invoice -> render model)
|
||||||
|
engine/ geometry (A4/Letter, margins), logo slots, layout fingerprint
|
||||||
|
templates/ <family>/ with tokens.ts, plan.ts, Layout.tsx; registry.ts, catalog.ts, contract.ts
|
||||||
|
blocks/ decor/ Shared layout blocks and decor
|
||||||
|
fonts/ Font registration, roles, glyph priming
|
||||||
|
render/ renderCore, Web Worker, latest-wins client
|
||||||
|
flatten.ts assemble.ts Rasterise at 300 DPI and rebuild an image-only PDF
|
||||||
|
testing/ Goldens (layout fingerprints), self-test fixtures, synthetic sample logo
|
||||||
|
src-tauri/src/ Rust backend
|
||||||
|
db.rs Schema as numbered migrations (copies the DB aside before migrating)
|
||||||
|
gst.rs Integer-paise money and GST maths
|
||||||
|
logo.rs Logo trimming, quantising, measuring
|
||||||
|
commands/ Tauri commands: invoice, series, clients, payments, presets, settings, assets,
|
||||||
|
archive, fonts, backup, files, raw (binary IPC), erpnext, logo
|
||||||
|
integrations/erpnext/ ERPNext client, mapping, discovery, push
|
||||||
|
selftest.rs Unattended real-webview self-test
|
||||||
|
public/fonts/ Bundled TTF families, OFL texts, manifest.json (plus legacy IBM Plex web fonts)
|
||||||
|
public/templates/ Template thumbnails (rendered from synthetic sample data)
|
||||||
|
docs/ Template specs, voiced.invoice.v1, release checklist
|
||||||
|
scripts/ Font, logo, template, thumbnail and ERPNext end-to-end tooling
|
||||||
|
packaging/void/ xbps-src template, build-xbps.sh, desktop entry, INSTALL and REMOVE scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ownership
|
## Tests and gates
|
||||||
|
|
||||||
© Bongbetic. All rights reserved. This is a private application.
|
Before a release (details in `docs/RELEASE.md`): `npm run build`, `npm test`, `npm run fonts:verify`,
|
||||||
|
`cargo test --manifest-path src-tauri/Cargo.toml`, and the real-webview self-test on a debug or installed build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri -- build --debug --no-bundle --features custom-protocol
|
||||||
|
VOICED_SELFTEST_OUT=/tmp/voiced-e2e/report.json XDG_DATA_HOME=/tmp/voiced-e2e/data \
|
||||||
|
XDG_CONFIG_HOME=/tmp/voiced-e2e/config timeout 180 src-tauri/target/debug/voiced
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is inert unless `VOICED_SELFTEST_OUT` is set; `ok: true` is expected in the report. The same checks run from
|
||||||
|
Settings, Diagnostics.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Windows and WebView2 have only been exercised in a Windows 11 virtual machine, not on physical hardware; the NSIS
|
||||||
|
installer is cross-compiled and unsigned.
|
||||||
|
- The SAC code for the vendor's services and the signature rule still need confirmation from a chartered accountant.
|
||||||
|
- Now, Gotham and Open Sauce One are replaced by free look-alikes unless you import licensed files.
|
||||||
|
- ERPNext: only v15 is verified; HTTP is allowed only for local hosts; see "Not covered" above.
|
||||||
|
- Not implemented (deferred): copies and watermarks, PDF/A, custom margins, user background textures, SVG logo
|
||||||
|
import, CESS, composition and SEZ invoices.
|
||||||
|
|
||||||
|
## Licence and credit
|
||||||
|
|
||||||
|
Voiced is released under the [Apache License 2.0](LICENSE). Copyright 2026 Bongbetic.
|
||||||
|
|
||||||
|
You are free to use, modify, fork and redistribute it. If you do, you must keep the `LICENSE` and `NOTICE` files and
|
||||||
|
the copyright notices, and state your changes; please also credit the original: **Based on Voiced by Bongbetic**.
|
||||||
|
Bundled fonts keep their own SIL Open Font License texts (see "Fonts and licences").
|
||||||
|
|
||||||
|
Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md). Contributions are licensed under the same terms.
|
||||||
|
|||||||
@@ -0,0 +1,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
|
## Mapping to Voiced fields
|
||||||
|
|
||||||
- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. public/brand/sample-wordmark-logo.png), hide the text lockup.
|
- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. src/pdf/testing/fixtures/sample-wordmark-logo.png), hide the text lockup.
|
||||||
- Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned).
|
- Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned).
|
||||||
- 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'.
|
- 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'.
|
||||||
- Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'.
|
- Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'.
|
||||||
|
|||||||
@@ -0,0 +1,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.
|
||||||
@@ -6,6 +6,17 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<title>Voiced</title>
|
<title>Voiced</title>
|
||||||
|
<script>
|
||||||
|
// Apply the last-used theme before first paint so a dark theme never flashes white.
|
||||||
|
(function () {
|
||||||
|
var t = "g10";
|
||||||
|
try {
|
||||||
|
t = localStorage.getItem("voiced.theme") === "g100" ? "g100" : "g10";
|
||||||
|
} catch (e) {}
|
||||||
|
document.documentElement.classList.add("cds--" + t);
|
||||||
|
document.documentElement.setAttribute("data-carbon-theme", t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "voiced",
|
"name": "voiced",
|
||||||
"version": "0.1.0",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "voiced",
|
"name": "voiced",
|
||||||
"version": "0.1.0",
|
"version": "1.0.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@carbon/icons-react": "^11.89.0",
|
"@carbon/icons-react": "^11.89.0",
|
||||||
"@carbon/react": "^1.117.0",
|
"@carbon/react": "^1.117.0",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "voiced",
|
"name": "voiced",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.0.2",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Bongbetic",
|
"author": "Bongbetic",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ mkdir -p "$REPO"
|
|||||||
-n "$PKGVER" \
|
-n "$PKGVER" \
|
||||||
-s "Offline invoicing app by Bongbetic" \
|
-s "Offline invoicing app by Bongbetic" \
|
||||||
-S "Voiced is an offline-first invoice generator with automatic numbering, GST support, optional signatures and print-ready PDF export." \
|
-S "Voiced is an offline-first invoice generator with automatic numbering, GST support, optional signatures and print-ready PDF export." \
|
||||||
-l "proprietary" \
|
-l "Apache-2.0" \
|
||||||
-H "https://git.bongbetic.com/xavierk/Voiced" \
|
-H "https://git.bongbetic.com/xavierk/Voiced" \
|
||||||
-m "Xavier Karma <xavier@bongbetic.com>" \
|
-m "Xavier Karma <xavier@bongbetic.com>" \
|
||||||
-D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
|
-D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
|
||||||
@@ -101,7 +101,9 @@ mkdir -p "$REPO"
|
|||||||
mv -f "$OUT"/${PKGVER}.*.xbps "$REPO/"
|
mv -f "$OUT"/${PKGVER}.*.xbps "$REPO/"
|
||||||
|
|
||||||
echo "==> Indexing local repository"
|
echo "==> Indexing local repository"
|
||||||
xbps-rindex -a "$REPO"/*.xbps
|
# -f re-adds a package whose version is already indexed. Without it a rebuild of the
|
||||||
|
# same version keeps the old checksum in the index and xbps-install rejects the new file.
|
||||||
|
xbps-rindex -f -a "$REPO"/*.xbps
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Package: $REPO/${PKGVER}.${ARCH}.xbps"
|
echo "Package: $REPO/${PKGVER}.${ARCH}.xbps"
|
||||||
|
|||||||
@@ -21,11 +21,14 @@
|
|||||||
# not install them. Copy them next to the template (see the commands above).
|
# not install them. Copy them next to the template (see the commands above).
|
||||||
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
|
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
|
||||||
# unless mkfontdir and mkfontscale are installed, hence these scripts.
|
# unless mkfontdir and mkfontscale are installed, hence these scripts.
|
||||||
# * If the build machine is offline, pre-populate the npm cache and Cargo
|
# * xbps-src builds without network access: vendor the crates (`cargo vendor`
|
||||||
# registry in the masterdir before building.
|
# in src-tauri/ plus a .cargo/config.toml pointing at vendor/) and pre-populate
|
||||||
|
# the npm cache in the source archive or masterdir before building.
|
||||||
|
# * No openssl-devel: the ERPNext client uses rustls (ring); reqwest and zip are
|
||||||
|
# pure Rust and need no extra system libraries.
|
||||||
#
|
#
|
||||||
pkgname=voiced
|
pkgname=voiced
|
||||||
version=0.1.0
|
version=1.0.2
|
||||||
revision=1
|
revision=1
|
||||||
archs="x86_64*"
|
archs="x86_64*"
|
||||||
hostmakedepends="rust nodejs pkg-config"
|
hostmakedepends="rust nodejs pkg-config"
|
||||||
@@ -33,7 +36,7 @@ makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel"
|
|||||||
depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
|
depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
|
||||||
short_desc="Offline invoicing app by Bongbetic"
|
short_desc="Offline invoicing app by Bongbetic"
|
||||||
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
||||||
license="custom:proprietary"
|
license="Apache-2.0"
|
||||||
homepage="https://git.bongbetic.com/xavierk/Voiced"
|
homepage="https://git.bongbetic.com/xavierk/Voiced"
|
||||||
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
|
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
|
||||||
checksum=SKIP
|
checksum=SKIP
|
||||||
@@ -43,7 +46,10 @@ compression="zstd"
|
|||||||
font_dirs="/usr/share/fonts/voiced"
|
font_dirs="/usr/share/fonts/voiced"
|
||||||
|
|
||||||
do_build() {
|
do_build() {
|
||||||
npm install --include=dev --no-audit --no-fund
|
# Not --omit=optional: that also drops rollup's and esbuild's native binaries (they are
|
||||||
|
# optional dependencies), so vite fails; and tsc in `npm run build` type-checks the
|
||||||
|
# @napi-rs/canvas test helpers, so that package must be installed too.
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
npm run build
|
npm run build
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
# `custom-protocol` embeds the frontend for production; without it the app
|
# `custom-protocol` embeds the frontend for production; without it the app
|
||||||
|
|||||||
|
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 |
@@ -0,0 +1,5 @@
|
|||||||
|
# frappe/erpnext v15 plus the india_compliance app (installed per site, not globally)
|
||||||
|
FROM docker.io/frappe/erpnext:v15.121.6
|
||||||
|
USER frappe
|
||||||
|
RUN bench get-app --skip-assets --branch version-15 https://github.com/resilient-tech/india-compliance.git \
|
||||||
|
&& ls -1 apps > sites/apps.txt || true
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# ERPNext live end-to-end (podman)
|
||||||
|
|
||||||
|
Runs Voiced's ERPNext integration against real ERPNext v15 sites: one plain site and one with India Compliance.
|
||||||
|
Everything is rootless podman (`podman` and `podman-compose`, never docker). All names are prefixed `voiced-erp`.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `compose.yml` | Lean stack: MariaDB, one Redis, backend (gunicorn on `127.0.0.1:8088`), one worker. Project name `voiced-erp`. |
|
||||||
|
| `Containerfile.ic` | `frappe/erpnext:v15.121.6` plus the `india_compliance` app files (`version-15` branch). |
|
||||||
|
| `setup.sh` | Builds the image, starts the stack, creates both sites, prepares them, writes `.env.a` and `.env.b`. |
|
||||||
|
| `setup_site.py` | Runs inside the container: company, accounts, address, items, integration user and keys. |
|
||||||
|
| `../../src-tauri/src/integrations/erpnext/live_tests.rs` | The `#[ignore]` Rust tests. |
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts/erpnext-e2e
|
||||||
|
./setup.sh # first run pulls ~2 GB of images and takes about 10 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup.sh` creates two sites that one backend serves by Host header:
|
||||||
|
|
||||||
|
| Site | URL | Contents | Env file |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `127.0.0.1` | `http://127.0.0.1:8088` | ERPNext only | `.env.a` |
|
||||||
|
| `localhost` | `http://localhost:8088` | ERPNext + India Compliance | `.env.b` |
|
||||||
|
|
||||||
|
Both get the company "Voiced Test Co" (abbreviation `VTC`, INR, India, fiscal year 2026-27), the output tax accounts
|
||||||
|
`Output Tax CGST|SGST|UTGST|IGST - VTC` (India Compliance creates its own with the same names), `TDS Receivable - VTC`,
|
||||||
|
the bank account `HDFC - VTC`, a company address, the UOMs Hour/Minute/Second, the items `VOICED-SERVICE` (stock UOM Nos)
|
||||||
|
and `VOICED-HOURLY` (stock UOM Hour), and an integration user with a fresh API key and secret.
|
||||||
|
`.env.*` files hold those keys; they are git-ignored (`.env.*` in the root `.gitignore`) and must never be committed.
|
||||||
|
|
||||||
|
Run the Rust tests (they only touch the site named by the sourced env file):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a; . scripts/erpnext-e2e/.env.a; set +a # or .env.b for the India Compliance site
|
||||||
|
cargo test --manifest-path src-tauri/Cargo.toml live_ -- --ignored --test-threads=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables the tests read:
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `ERPNEXT_URL`, `ERPNEXT_KEY`, `ERPNEXT_SECRET` | Site address and the integration user's API key and secret. |
|
||||||
|
| `ERPNEXT_COMPANY` (`Voiced Test Co`), `ERPNEXT_ABBR` (`VTC`) | Company; account names derive from the abbreviation. |
|
||||||
|
| `ERPNEXT_IC` | `1` on the India Compliance site; the `live_ic_*` tests skip themselves otherwise. |
|
||||||
|
| `ERPNEXT_ROUNDING` | `banker` (ERPNext default) or `commercial`; decides what the half-paise test expects. |
|
||||||
|
| `ERPNEXT_BANK_ACCOUNT` (`HDFC - VTC`) | Account that receives payments. |
|
||||||
|
|
||||||
|
Each test builds its own in-memory Voiced database and a fresh invoice series (`L<time><n>/001`), so reruns never collide
|
||||||
|
with documents from earlier runs. Switch the rounding method of a site with
|
||||||
|
`podman exec -i voiced-erp-backend bash -c "cd /home/frappe/frappe-bench/sites && /home/frappe/frappe-bench/env/bin/python - 127.0.0.1 rounding=commercial" < setup_site.py`
|
||||||
|
(re-run `setup.sh` or copy the printed `ERPNEXT_*` lines if you need the new key; the key is regenerated on every run).
|
||||||
|
|
||||||
|
Tear down (touches only the `voiced-erp` project; the cached images stay):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman-compose -p voiced-erp -f compose.yml down -v
|
||||||
|
podman volume rm voiced-erp_voiced-erp-db voiced-erp_voiced-erp-sites voiced-erp_voiced-erp-logs # only if `down -v` left them
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes on rootless podman: the compose file has no healthchecks (podman's healthcheck timers need systemd), and the
|
||||||
|
backend and worker are started by `setup.sh` after the configurator has exited.
|
||||||
|
|
||||||
|
## Integration user (feeds the main README)
|
||||||
|
|
||||||
|
Create a dedicated user in ERPNext (User list, type System User) with **the roles "Accounts User" and "Sales User"**.
|
||||||
|
Then open the user, "Settings" tab, "API Access", "Generate Keys", and copy the secret once.
|
||||||
|
|
||||||
|
Why both (verified live on plain ERPNext and with India Compliance; `setup_site.py` creates exactly this user):
|
||||||
|
|
||||||
|
- "Accounts User": create and submit Sales Invoices (draft, insert-and-submit, submit-later) and Payment Entries; read
|
||||||
|
Accounts, Items, UOMs, Cost Centers, Price Lists, Item Groups, Company, the Sales Invoice naming series and (with
|
||||||
|
India Compliance) GST Settings; attach a private PDF to the invoice.
|
||||||
|
- "Sales User": look up and create Customers and Addresses, read Customer Groups, Territories and the tax templates.
|
||||||
|
With "Accounts User" alone a fresh site answers HTTP 403 on Customer.
|
||||||
|
|
||||||
|
No tax template is used (Voiced always sends explicit tax rows), so the tax-template list is only a convenience.
|
||||||
|
Do not use Administrator or System Manager keys. Roles on a user are cached by Frappe: after changing them run
|
||||||
|
`bench clear-cache` or the next request may still use the old set.
|
||||||
|
|
||||||
|
## Results (what was verified live)
|
||||||
|
|
||||||
|
Versions: Frappe 15.121.3, ERPNext 15.121.6, India Compliance 15.32.0 (the `version-15` branch; commit `db73b3e` at the time of this run, so a later image build may differ), MariaDB 11.8,
|
||||||
|
Redis 8.6, API v2 naming available (Frappe 15.73 or newer).
|
||||||
|
|
||||||
|
Verified on both sites unless marked IC (India Compliance site only):
|
||||||
|
|
||||||
|
- Connection test: user, frappe/erpnext/india_compliance versions, India Compliance detection, no warnings when the
|
||||||
|
accounts match. Option lists load (companies, addresses, accounts, UOMs, cost centers, groups, series).
|
||||||
|
- Sales Invoice push in mirror mode (API v2, `name` = Voiced number): CGST+SGST, IGST, discount (`apply_discount_on:
|
||||||
|
Net Total`), fractional hours, repeated descriptions on code-less rows, item-code rows from a preset, remarks. The
|
||||||
|
grand total, the tax amounts per head and the posting date equal Voiced's; `disable_rounded_total` works.
|
||||||
|
- PDF attachment: uploaded through `upload_file`, `is_private` is 1.
|
||||||
|
- Idempotency: a second push is a no-op; with the local sync row deleted, the 409 path adopts the existing document
|
||||||
|
(no duplicate, no second PDF).
|
||||||
|
- Submit: insert then submit in a second push (v2 `method/submit`), and series mode through the v1 `run_method` route.
|
||||||
|
- Series mode: ERPNext names the document, the Voiced number is in `remarks`, and a lost sync row is recovered by the
|
||||||
|
remarks lookup.
|
||||||
|
- Payments: `get_payment_entry` plus overrides. `bank_account` is the Account name and lands in `paid_to`;
|
||||||
|
`paid_amount = received_amount = cash`; the reference's `allocated_amount = cash + TDS`; TDS is one `deductions` row
|
||||||
|
`{account, cost_center, amount: +TDS}`; the invoice's outstanding falls by cash + TDS; partial payments leave the rest.
|
||||||
|
- Customer and Address shape (IC: `gstin`, `gst_category` on both; state exact name; link to the Customer).
|
||||||
|
- IC: `place_of_supply` as `29-Karnataka`; Voiced's 38 state names equal India Compliance's `gst_state` list; HSN/SAC
|
||||||
|
must be 6 or 8 digits (GST Settings `min_hsn_digits` was 6) and is enforced at submit; the 16-character name rule;
|
||||||
|
GST-account warnings (mismatching CGST account, company without GST accounts, a too-long next number).
|
||||||
|
|
||||||
|
Defects found and fixed (each has a mock-based test in the normal suite):
|
||||||
|
|
||||||
|
1. `load_options`: the company-address list asked for the India Compliance-only `gstin` field and failed with HTTP 417
|
||||||
|
on a plain site. It now retries without it.
|
||||||
|
2. Code-less rows with a fractional quantity of hours/minutes failed ("Quantity cannot be a fraction ... UOM Nos"):
|
||||||
|
ERPNext defaults the row's stock UOM to Nos without an item. Such rows now also send `stock_uom` = the row UOM.
|
||||||
|
3. Rounding: ERPNext's default Rounding Method is Banker's Rounding; Voiced rounds half-paise up. On ties (for example
|
||||||
|
9% of 10.50) the totals differ by 1 paise. A created document whose total differs is now a `conflict`: kept as a
|
||||||
|
draft, never submitted, no PDF, with a message naming the fix (System Settings, Rounding Method, "Commercial
|
||||||
|
Rounding"). The existing-document conflict message carries the same hint. With Commercial Rounding every case matched.
|
||||||
|
4. A lost link to a client's address created a new address on every first push. The customer's matching address (same
|
||||||
|
first line and PIN) is reused. (`creation` in the order-by is ambiguous with a Dynamic Link filter and gives HTTP 500.)
|
||||||
|
5. A re-adopted document got a second copy of the PDF. A file of the same name is now looked up first.
|
||||||
|
6. IC refuses `is_reverse_charge = 1` unless the tax rows are negative amounts on separate "RCM" accounts, which would
|
||||||
|
make ERPNext's total differ from Voiced's. It is sent as 0 and the push warns.
|
||||||
|
7. IC's 16-character rule is now checked before the POST in mirror mode (a readable, local refusal that points to series
|
||||||
|
naming); ERPNext's own message is "Transaction Name must be 16 characters or fewer to meet GST requirements".
|
||||||
|
8. A TDS-only payment (no cash) got ERPNext's bare "Paid Amount is mandatory"; it is refused locally with the reason.
|
||||||
|
9. A fractional quantity on a whole-number UOM is refused by ERPNext with a readable message; Voiced appends what to
|
||||||
|
change (unit mapping). No local pre-check: whether "Nos" is whole-number is a site setting.
|
||||||
|
|
||||||
|
Limits and notes:
|
||||||
|
|
||||||
|
- Rounding setting cannot be read by a restricted user, so it is detected by comparing the totals after creating the
|
||||||
|
draft, not in the connection test.
|
||||||
|
- With India Compliance, an invoice with a missing or short HSN/SAC is created as a draft and fails at submit; the
|
||||||
|
readable ERPNext message ("HSN/SAC must exist and should be 6 or 8 digits long for the following row numbers") is
|
||||||
|
shown.
|
||||||
|
- A mirrored item row with an item code whose stock UOM differs from the row UOM works for whole quantities
|
||||||
|
(`conversion_factor` 1); a fractional quantity needs the item's stock UOM to allow fractions.
|
||||||
|
- One intermittent failure was seen once in the half-paise test on the India Compliance site (the push of a non-tie
|
||||||
|
case failed; three later full runs and a single-test run passed). The cause was not captured.
|
||||||
|
|
||||||
|
Not verified:
|
||||||
|
|
||||||
|
- ERPNext v14 and v16 (the v16 images that exist on some machines were not used), other India Compliance versions.
|
||||||
|
- Windows and WebView2; the Tauri commands and UI (the tests call the Rust push/payment functions directly).
|
||||||
|
- Overseas/SEZ customers, UTGST supplier states, multi-currency, GST Settings with "Round Off GST Values" on,
|
||||||
|
reverse charge booked on RCM accounts, e-invoice/e-waybill (India Compliance API features), a TLS or reverse-proxied
|
||||||
|
site (only plain `http://` on a loopback address), Frappe Cloud or any site with `allow_cors` or rate limits.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Lean ERPNext v15 stack for Voiced's live integration tests (project name: voiced-erp).
|
||||||
|
# db + redis + backend (gunicorn, published on 127.0.0.1:8088) + one worker. No nginx/websocket/scheduler.
|
||||||
|
# setup.sh starts it in order (db, redis, configurator, then backend and worker); do not `up` it blindly.
|
||||||
|
# Image: voiced-erp-ic:v15 = frappe/erpnext v15 + the india_compliance app files (see Containerfile.ic).
|
||||||
|
name: voiced-erp
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: docker.io/library/mariadb:11.8
|
||||||
|
container_name: voiced-erp-db
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
- --skip-character-set-client-handshake
|
||||||
|
- --innodb-buffer-pool-size=256M
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: admin
|
||||||
|
volumes:
|
||||||
|
- voiced-erp-db:/var/lib/mysql
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: docker.io/library/redis:8.6-alpine
|
||||||
|
container_name: voiced-erp-redis
|
||||||
|
|
||||||
|
configurator:
|
||||||
|
image: localhost/voiced-erp-ic:v15
|
||||||
|
container_name: voiced-erp-configurator
|
||||||
|
restart: "no"
|
||||||
|
entrypoint: ["bash", "-c"]
|
||||||
|
command:
|
||||||
|
- >
|
||||||
|
wait-for-it -t 120 db:3306;
|
||||||
|
ls -1 apps > sites/apps.txt;
|
||||||
|
bench set-config -g db_host db;
|
||||||
|
bench set-config -gp db_port 3306;
|
||||||
|
bench set-config -g redis_cache redis://redis:6379;
|
||||||
|
bench set-config -g redis_queue redis://redis:6379;
|
||||||
|
bench set-config -g redis_socketio redis://redis:6379;
|
||||||
|
bench set-config -gp socketio_port 9000;
|
||||||
|
volumes:
|
||||||
|
- voiced-erp-sites:/home/frappe/frappe-bench/sites
|
||||||
|
- voiced-erp-logs:/home/frappe/frappe-bench/logs
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: localhost/voiced-erp-ic:v15
|
||||||
|
container_name: voiced-erp-backend
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8088:8000"
|
||||||
|
volumes:
|
||||||
|
- voiced-erp-sites:/home/frappe/frappe-bench/sites
|
||||||
|
- voiced-erp-logs:/home/frappe/frappe-bench/logs
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: localhost/voiced-erp-ic:v15
|
||||||
|
container_name: voiced-erp-worker
|
||||||
|
command: ["bench", "worker", "--queue", "short,default,long"]
|
||||||
|
volumes:
|
||||||
|
- voiced-erp-sites:/home/frappe/frappe-bench/sites
|
||||||
|
- voiced-erp-logs:/home/frappe/frappe-bench/logs
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voiced-erp-db:
|
||||||
|
voiced-erp-sites:
|
||||||
|
voiced-erp-logs:
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Brings up the voiced-erp podman project and prepares two ERPNext v15 sites for the live tests:
|
||||||
|
# 127.0.0.1 plain ERPNext -> .env.a (URL http://127.0.0.1:8088)
|
||||||
|
# localhost ERPNext + India Compliance -> .env.b (URL http://localhost:8088)
|
||||||
|
# Both are served by the same backend; Frappe picks the site from the Host header.
|
||||||
|
# Safe to re-run: existing sites are kept and the integration user's key is regenerated.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
COMPOSE=(podman-compose -p voiced-erp -f compose.yml)
|
||||||
|
IMAGE=localhost/voiced-erp-ic:v15
|
||||||
|
BENCH_SITES=/home/frappe/frappe-bench/sites
|
||||||
|
|
||||||
|
podman image exists "$IMAGE" || podman build -t "$IMAGE" -f Containerfile.ic .
|
||||||
|
|
||||||
|
"${COMPOSE[@]}" up -d db redis configurator
|
||||||
|
until [ "$(podman inspect -f '{{.State.Status}}' voiced-erp-configurator)" = exited ]; do sleep 2; done
|
||||||
|
[ "$(podman inspect -f '{{.State.ExitCode}}' voiced-erp-configurator)" = 0 ] || { echo "configurator failed: podman logs voiced-erp-configurator" >&2; exit 1; }
|
||||||
|
"${COMPOSE[@]}" up -d backend worker
|
||||||
|
|
||||||
|
in_backend() { podman exec -i voiced-erp-backend bash -c "$1"; }
|
||||||
|
|
||||||
|
for site in 127.0.0.1 localhost; do
|
||||||
|
default=""; [ "$site" = 127.0.0.1 ] && default="--set-default"
|
||||||
|
in_backend "test -d $BENCH_SITES/$site || bench new-site $site --mariadb-user-host-login-scope='%' \
|
||||||
|
--admin-password=admin --db-root-username=root --db-root-password=admin --install-app erpnext $default" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
until curl -fsS http://127.0.0.1:8088/api/method/ping >/dev/null 2>&1; do sleep 2; done
|
||||||
|
|
||||||
|
# setup_site.py prints `ENV KEY=VALUE` lines; turn them into a sourceable env file (untracked: .env.* is ignored).
|
||||||
|
write_env() { # site, url, file, extra args...
|
||||||
|
local site=$1 url=$2 file=$3; shift 3
|
||||||
|
in_backend "cd $BENCH_SITES && /home/frappe/frappe-bench/env/bin/python - $site $*" < setup_site.py \
|
||||||
|
| sed -n 's/^ENV \([A-Z_]*\)=\(.*\)$/export \1="\2"/p' > "$file"
|
||||||
|
echo "export ERPNEXT_URL=\"$url\"" >> "$file"
|
||||||
|
chmod 600 "$file"
|
||||||
|
}
|
||||||
|
write_env 127.0.0.1 http://127.0.0.1:8088 .env.a
|
||||||
|
write_env localhost http://localhost:8088 .env.b ic
|
||||||
|
|
||||||
|
echo "Ready. Source .env.a (plain) or .env.b (India Compliance), then run the live tests (see README.md)."
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Prepare one ERPNext site for Voiced's live integration tests.
|
||||||
|
|
||||||
|
Run inside the backend container (see README.md):
|
||||||
|
podman exec -i voiced-erp-backend python - <site> [ic] [rounding=commercial] < setup_site.py
|
||||||
|
|
||||||
|
Creates (idempotently): the Indian company "Voiced Test Co" (abbr VTC, INR), the fiscal year, GST output tax
|
||||||
|
accounts, a bank account, a company address, an integration user with API keys, and a few Items/UOMs.
|
||||||
|
Prints one line `ENV KEY=VALUE` per value the Rust live tests need. Never run against a real site.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
SITE = sys.argv[1]
|
||||||
|
IC = "ic" in sys.argv[2:]
|
||||||
|
# `rounding=commercial` switches System Settings > Rounding Method (the ERPNext default is Banker's Rounding).
|
||||||
|
ROUNDING = {"commercial": "Commercial Rounding", "banker": "Banker's Rounding"}.get(
|
||||||
|
next((a.split("=", 1)[1] for a in sys.argv[2:] if a.startswith("rounding=")), ""), None
|
||||||
|
)
|
||||||
|
ABBR = "VTC"
|
||||||
|
COMPANY = "Voiced Test Co"
|
||||||
|
|
||||||
|
frappe.init(site=SITE, sites_path="/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.set_user("Administrator")
|
||||||
|
|
||||||
|
|
||||||
|
def out(key, value):
|
||||||
|
print(f"ENV {key}={value}")
|
||||||
|
|
||||||
|
|
||||||
|
def exists(doctype, name):
|
||||||
|
return bool(frappe.db.exists(doctype, name))
|
||||||
|
|
||||||
|
|
||||||
|
def insert(doc):
|
||||||
|
d = frappe.get_doc(doc)
|
||||||
|
d.insert(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 1. site, optionally India Compliance (before the wizard so it can add its own accounts) ----
|
||||||
|
if IC and "india_compliance" not in frappe.get_installed_apps():
|
||||||
|
from frappe.installer import install_app
|
||||||
|
|
||||||
|
install_app("india_compliance", verbose=False)
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# ---- 2. setup wizard ----
|
||||||
|
if not frappe.db.get_single_value("System Settings", "setup_complete"):
|
||||||
|
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
|
||||||
|
|
||||||
|
setup_complete(
|
||||||
|
{
|
||||||
|
"language": "English",
|
||||||
|
"country": "India",
|
||||||
|
"timezone": "Asia/Kolkata",
|
||||||
|
"currency": "INR",
|
||||||
|
"full_name": "Voiced Admin",
|
||||||
|
"email": "admin@voiced.test",
|
||||||
|
"password": "admin",
|
||||||
|
"company_name": COMPANY,
|
||||||
|
"company_abbr": ABBR,
|
||||||
|
"domains": ["Services"],
|
||||||
|
"chart_of_accounts": "Standard",
|
||||||
|
"fy_start_date": "2026-04-01",
|
||||||
|
"fy_end_date": "2027-03-31",
|
||||||
|
"bank_account": "HDFC",
|
||||||
|
"setup_demo": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# setup_complete may run its stages on a worker; wait for it.
|
||||||
|
for _ in range(120):
|
||||||
|
frappe.db.rollback()
|
||||||
|
if frappe.db.get_single_value("System Settings", "setup_complete") and exists("Company", COMPANY):
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
frappe.clear_cache()
|
||||||
|
frappe.db.commit()
|
||||||
|
assert exists("Company", COMPANY), "setup wizard did not create the company"
|
||||||
|
|
||||||
|
# ---- 3. accounts ----
|
||||||
|
duties = frappe.db.get_value("Account", {"company": COMPANY, "account_name": "Duties and Taxes", "is_group": 1}, "name")
|
||||||
|
assert duties, "Duties and Taxes group not found"
|
||||||
|
for head in ("CGST", "SGST", "UTGST", "IGST"):
|
||||||
|
name = f"Output Tax {head} - {ABBR}"
|
||||||
|
if not exists("Account", name):
|
||||||
|
insert(
|
||||||
|
{
|
||||||
|
"doctype": "Account",
|
||||||
|
"account_name": f"Output Tax {head}",
|
||||||
|
"parent_account": duties,
|
||||||
|
"company": COMPANY,
|
||||||
|
"account_type": "Tax",
|
||||||
|
"root_type": "Liability",
|
||||||
|
"is_group": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tds = f"TDS Receivable - {ABBR}"
|
||||||
|
if not exists("Account", tds):
|
||||||
|
parent = frappe.db.get_value("Account", {"company": COMPANY, "account_name": "Current Assets", "is_group": 1}, "name")
|
||||||
|
insert(
|
||||||
|
{
|
||||||
|
"doctype": "Account",
|
||||||
|
"account_name": "TDS Receivable",
|
||||||
|
"parent_account": parent,
|
||||||
|
"company": COMPANY,
|
||||||
|
"account_type": "Tax",
|
||||||
|
"root_type": "Asset",
|
||||||
|
"is_group": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 4. company address (India Compliance needs one with a GSTIN-ish state) ----
|
||||||
|
addr_name = f"{COMPANY}-Billing"
|
||||||
|
if not exists("Address", addr_name):
|
||||||
|
d = {
|
||||||
|
"doctype": "Address",
|
||||||
|
"address_title": COMPANY,
|
||||||
|
"address_type": "Billing",
|
||||||
|
"address_line1": "1 Test Street",
|
||||||
|
"city": "Mumbai",
|
||||||
|
"state": "Maharashtra",
|
||||||
|
"pincode": "400001",
|
||||||
|
"country": "India",
|
||||||
|
"links": [{"link_doctype": "Company", "link_name": COMPANY}],
|
||||||
|
}
|
||||||
|
if IC:
|
||||||
|
d["gst_category"] = "Registered Regular"
|
||||||
|
d["gstin"] = "27AAPFU0939F1ZV"
|
||||||
|
insert(d)
|
||||||
|
|
||||||
|
# ---- 5. items / UOMs ----
|
||||||
|
for uom in ("Hour", "Minute", "Second"):
|
||||||
|
if not exists("UOM", uom):
|
||||||
|
insert({"doctype": "UOM", "uom_name": uom})
|
||||||
|
for code, uom in (("VOICED-SERVICE", "Nos"), ("VOICED-HOURLY", "Hour")):
|
||||||
|
if not exists("Item", code):
|
||||||
|
d = {
|
||||||
|
"doctype": "Item",
|
||||||
|
"item_code": code,
|
||||||
|
"item_name": code,
|
||||||
|
"item_group": "Services",
|
||||||
|
"stock_uom": uom,
|
||||||
|
"is_stock_item": 0,
|
||||||
|
}
|
||||||
|
if IC:
|
||||||
|
d["gst_hsn_code"] = "998314"
|
||||||
|
insert(d)
|
||||||
|
|
||||||
|
# ---- 5b. rounding method ----
|
||||||
|
if ROUNDING:
|
||||||
|
frappe.db.set_single_value("System Settings", "rounding_method", ROUNDING)
|
||||||
|
frappe.db.commit()
|
||||||
|
frappe.clear_cache()
|
||||||
|
|
||||||
|
# ---- 6. integration user ----
|
||||||
|
USER = f"voiced-integration@{SITE.replace('.', '-')}.test"
|
||||||
|
# The role set that was verified live (with and without India Compliance): "Accounts User" creates and submits Sales
|
||||||
|
# Invoices and Payment Entries and reads the accounting masters; "Sales User" adds the Customer and Address rights
|
||||||
|
# (look up, create) and the tax-template list. Accounts User alone gets HTTP 403 on Customer.
|
||||||
|
ROLES = ["Accounts User", "Sales User"]
|
||||||
|
if not exists("User", USER):
|
||||||
|
insert(
|
||||||
|
{
|
||||||
|
"doctype": "User",
|
||||||
|
"email": USER,
|
||||||
|
"first_name": "Voiced",
|
||||||
|
"last_name": "Integration",
|
||||||
|
"send_welcome_email": 0,
|
||||||
|
"user_type": "System User",
|
||||||
|
"roles": [{"role": r} for r in ROLES],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user = frappe.get_doc("User", USER)
|
||||||
|
user.set("roles", [{"role": r} for r in ROLES])
|
||||||
|
secret = frappe.generate_hash(length=32)
|
||||||
|
if not user.api_key:
|
||||||
|
user.api_key = frappe.generate_hash(length=15)
|
||||||
|
user.api_secret = secret
|
||||||
|
user.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
frappe.clear_cache(user=USER)
|
||||||
|
frappe.clear_cache()
|
||||||
|
|
||||||
|
out("ERPNEXT_KEY", user.api_key)
|
||||||
|
out("ERPNEXT_SECRET", secret)
|
||||||
|
out("ERPNEXT_COMPANY", COMPANY)
|
||||||
|
out("ERPNEXT_ABBR", ABBR)
|
||||||
|
out("ERPNEXT_IC", "1" if IC else "0")
|
||||||
|
out("ERPNEXT_USER", USER)
|
||||||
|
out("ERPNEXT_ROUNDING", "commercial" if frappe.db.get_single_value("System Settings", "rounding_method") == "Commercial Rounding" else "banker")
|
||||||
|
print(json.dumps({"roles": ROLES}))
|
||||||
|
frappe.destroy()
|
||||||
@@ -116,7 +116,7 @@ async function main() {
|
|||||||
const { computeLogoPlacement, slotFor } = await loadPlacement();
|
const { computeLogoPlacement, slotFor } = await loadPlacement();
|
||||||
mkdirSync(OUT_DIR, { recursive: true });
|
mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|
||||||
const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "public/brand/sample-wordmark-logo.png")));
|
const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "src/pdf/testing/fixtures/sample-wordmark-logo.png")));
|
||||||
const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height);
|
const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height);
|
||||||
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
|
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
|
||||||
const syn = syntheticLogos(createCanvas);
|
const syn = syntheticLogos(createCanvas);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Renders the synthetic wordmark used as the sample logo in tests, thumbnails and scripts
|
||||||
|
// (src/pdf/testing/fixtures/sample-wordmark-logo.png). Run: node scripts/logo/make-sample-logo.mjs
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { GlobalFonts, createCanvas } from "@napi-rs/canvas";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const W = 2172;
|
||||||
|
const H = 724;
|
||||||
|
const size = Number(process.argv[2] ?? 300);
|
||||||
|
GlobalFonts.registerFromPath(path.join(ROOT, process.argv[3] ?? "public/fonts/montserrat/Montserrat-ExtraBold.ttf"), "SampleMark");
|
||||||
|
const canvas = createCanvas(W, H);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.fillStyle = "#1f3a6e";
|
||||||
|
ctx.font = `${size}px SampleMark`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(process.argv[4] ?? "STUDIO", W / 2, H / 2);
|
||||||
|
writeFileSync(path.join(ROOT, "src/pdf/testing/fixtures/sample-wordmark-logo.png"), canvas.toBuffer("image/png"));
|
||||||
@@ -509,6 +509,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -532,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -545,10 +555,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core_detect"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -875,6 +891,20 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"core_detect",
|
||||||
|
"multiversion_no_op",
|
||||||
|
"rustversion",
|
||||||
|
"scopeguard",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1244,6 +1274,17 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -1415,6 +1456,25 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1531,6 +1591,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1541,6 +1602,21 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.21"
|
version = "0.1.21"
|
||||||
@@ -1560,9 +1636,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1891,6 +1969,36 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-macros",
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
"log",
|
||||||
|
"simd_cesu8",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"walkdir",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-macros"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"simd_cesu8",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2090,6 +2198,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2152,6 +2270,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multiversion_no_op"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2439,6 +2563,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2866,21 +2996,31 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -2916,6 +3056,20 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsqlite-vfs"
|
name = "rsqlite-vfs"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2979,12 +3133,91 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"jni 0.22.4",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-platform-verifier-android",
|
||||||
|
"rustls-webpki",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"webpki-root-certs",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier-android"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -2994,6 +3227,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3051,6 +3293,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -3175,6 +3440,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.24.0"
|
version = "3.24.0"
|
||||||
@@ -3272,6 +3549,22 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_cesu8"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||||
|
dependencies = [
|
||||||
|
"rustc_version",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3396,6 +3689,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
@@ -3459,6 +3758,27 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3480,7 +3800,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -3490,7 +3810,7 @@ dependencies = [
|
|||||||
"gdkwayland-sys",
|
"gdkwayland-sys",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
"gtk",
|
"gtk",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
@@ -3546,7 +3866,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -3763,7 +4083,7 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
@@ -3786,7 +4106,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
@@ -3972,9 +4292,31 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.19"
|
version = "0.7.19"
|
||||||
@@ -3984,6 +4326,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
|
"libc",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@@ -4188,6 +4531,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4211,6 +4560,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.26"
|
version = "1.0.26"
|
||||||
@@ -4223,6 +4578,12 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4286,15 +4647,17 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voiced"
|
name = "voiced"
|
||||||
version = "0.1.0"
|
version = "1.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"flate2",
|
"flate2",
|
||||||
"image",
|
"image",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rusqlite_migration",
|
"rusqlite_migration",
|
||||||
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -4306,7 +4669,9 @@ dependencies = [
|
|||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4508,6 +4873,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-root-certs"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.39.1"
|
version = "0.39.1"
|
||||||
@@ -4672,6 +5046,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -4699,6 +5084,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -4988,7 +5382,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -5149,6 +5543,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -5182,6 +5582,19 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "8.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"flate2",
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"memchr",
|
||||||
|
"typed-path",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voiced"
|
name = "voiced"
|
||||||
version = "0.1.0"
|
version = "1.0.2"
|
||||||
|
license = "Apache-2.0"
|
||||||
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
||||||
authors = ["Bongbetic"]
|
authors = ["Bongbetic"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -28,8 +29,14 @@ thiserror = "2"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
|
# ERPNext integration: all HTTP runs in Rust. TLS is rustls with the `ring` provider only (no aws-lc,
|
||||||
|
# which does not cross-compile cleanly under cargo-xwin); roots come from the OS via rustls-platform-verifier.
|
||||||
|
reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "http2", "charset", "system-proxy", "json", "multipart", "query"] }
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||||
|
tokio = { version = "1", features = ["time"] }
|
||||||
|
|
||||||
# Desktop-only plugins (not available on mobile targets).
|
# Desktop-only plugins (not available on mobile targets).
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
@@ -38,6 +45,7 @@ tauri-plugin-window-state = "2"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
tokio = { version = "1", features = ["rt", "macros", "net", "io-util", "time"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Production builds embed the frontend and serve it over the custom asset
|
# Production builds embed the frontend and serve it over the custom asset
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
use crate::gst;
|
||||||
use crate::models::Client;
|
use crate::models::Client;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use rusqlite::{params, Row};
|
use rusqlite::{params, Connection, Row};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
|
const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"];
|
||||||
|
|
||||||
|
const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
|
||||||
|
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
|
||||||
|
c.payment_terms_days,
|
||||||
|
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
|
||||||
|
|
||||||
fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
||||||
Ok(Client {
|
Ok(Client {
|
||||||
id: Some(row.get(0)?),
|
id: Some(row.get(0)?),
|
||||||
@@ -12,17 +20,51 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
|||||||
state_code: row.get(4)?,
|
state_code: row.get(4)?,
|
||||||
po_number: row.get(5)?,
|
po_number: row.get(5)?,
|
||||||
created_at: row.get(6)?,
|
created_at: row.get(6)?,
|
||||||
|
address_line1: row.get(7)?,
|
||||||
|
address_line2: row.get(8)?,
|
||||||
|
city: row.get(9)?,
|
||||||
|
pincode: row.get(10)?,
|
||||||
|
gst_category: row.get(11)?,
|
||||||
|
default_notes: row.get(12)?,
|
||||||
|
payment_terms_days: row.get(13)?,
|
||||||
|
invoice_count: row.get(14)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
|
||||||
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String {
|
||||||
|
let state_name = gst::STATES
|
||||||
|
.iter()
|
||||||
|
.find(|(c, _)| *c == state_code)
|
||||||
|
.map(|(_, n)| *n)
|
||||||
|
.unwrap_or("");
|
||||||
|
let place = [city.trim(), state_name]
|
||||||
|
.iter()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let pin = pincode.trim();
|
||||||
|
let last = match (place.is_empty(), pin.is_empty()) {
|
||||||
|
(false, false) => format!("{place} - {pin}"),
|
||||||
|
(false, true) => place,
|
||||||
|
(true, false) => pin.to_string(),
|
||||||
|
(true, true) => String::new(),
|
||||||
|
};
|
||||||
|
[line1.trim(), line2.trim(), last.as_str()]
|
||||||
|
.iter()
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_clients_impl(conn: &Connection) -> Result<Vec<Client>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(&format!(
|
||||||
"SELECT id, name, address, gstin, state_code, po_number, created_at
|
"SELECT {CLIENT_COLS} FROM clients c ORDER BY c.name COLLATE NOCASE ASC"
|
||||||
FROM clients ORDER BY name COLLATE NOCASE ASC",
|
))
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], map_client)
|
.query_map([], map_client)
|
||||||
@@ -32,29 +74,101 @@ pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn save_client_impl(conn: &Connection, client: Client) -> Result<Client, String> {
|
||||||
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
let name = client.name.trim().to_string();
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
if name.is_empty() {
|
||||||
|
return Err("Client name is required".into());
|
||||||
|
}
|
||||||
|
let gstin = match client.gstin.trim().to_ascii_uppercase().as_str() {
|
||||||
|
"" | "NA" => String::new(),
|
||||||
|
g => {
|
||||||
|
gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?;
|
||||||
|
g.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let state_code = client.state_code.trim().to_string();
|
||||||
|
if !state_code.is_empty() && !gst::state_exists(&state_code) {
|
||||||
|
return Err(format!("\"{state_code}\" is not a valid GST state code"));
|
||||||
|
}
|
||||||
|
if !gstin.is_empty() && !state_code.is_empty() && gstin[0..2] != state_code {
|
||||||
|
return Err(format!(
|
||||||
|
"GSTIN starts with state code {}, but the client's state is {}",
|
||||||
|
&gstin[0..2],
|
||||||
|
state_code
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !CATEGORIES.contains(&client.gst_category.as_str()) {
|
||||||
|
return Err(format!("Unknown GST category \"{}\"", client.gst_category));
|
||||||
|
}
|
||||||
|
let pincode = client.pincode.trim().to_string();
|
||||||
|
if !pincode.is_empty() && !(pincode.len() == 6 && pincode.bytes().all(|b| b.is_ascii_digit())) {
|
||||||
|
return Err("Pincode must be 6 digits".into());
|
||||||
|
}
|
||||||
|
if let Some(days) = client.payment_terms_days {
|
||||||
|
if !(0..=365).contains(&days) {
|
||||||
|
return Err("Payment terms must be between 0 and 365 days".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let line1 = client.address_line1.trim().to_string();
|
||||||
|
let line2 = client.address_line2.trim().to_string();
|
||||||
|
let city = client.city.trim().to_string();
|
||||||
|
// The structured parts win when present; otherwise the incoming (legacy or free-text) address is kept.
|
||||||
|
let address = if line1.is_empty() && line2.is_empty() && city.is_empty() && pincode.is_empty() {
|
||||||
|
client.address.clone()
|
||||||
|
} else {
|
||||||
|
compose_address(&line1, &line2, &city, &state_code, &pincode)
|
||||||
|
};
|
||||||
|
|
||||||
let id = match client.id {
|
let id = match client.id {
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
conn.execute(
|
let n = conn
|
||||||
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6",
|
.execute(
|
||||||
params![client.name, client.address, client.gstin, client.state_code, client.po_number, id],
|
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5,
|
||||||
)
|
address_line1=?6, address_line2=?7, city=?8, pincode=?9, gst_category=?10,
|
||||||
.map_err(|e| e.to_string())?;
|
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
|
id
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
address_line1, address_line2, city, pincode, gst_category, default_notes,
|
||||||
|
payment_terms_days)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
params![
|
params![
|
||||||
client.name,
|
name,
|
||||||
client.address,
|
address,
|
||||||
client.gstin,
|
gstin,
|
||||||
client.state_code,
|
state_code,
|
||||||
client.po_number,
|
client.po_number,
|
||||||
chrono::Utc::now().to_rfc3339()
|
chrono::Utc::now().to_rfc3339(),
|
||||||
|
line1,
|
||||||
|
line2,
|
||||||
|
city,
|
||||||
|
pincode,
|
||||||
|
client.gst_category,
|
||||||
|
client.default_notes,
|
||||||
|
client.payment_terms_days
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -62,17 +176,176 @@ pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, Str
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1",
|
&format!("SELECT {CLIENT_COLS} FROM clients c WHERE c.id = ?1"),
|
||||||
params![id],
|
params![id],
|
||||||
map_client,
|
map_client,
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Deleting is allowed even when invoices reference the client: invoices carry their own
|
||||||
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
/// name/address/GSTIN snapshot and `invoices.client_id` has no foreign key, so issued invoices are
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
/// untouched (their id simply no longer resolves). The UI warns with `invoice_count` first.
|
||||||
|
pub fn delete_client_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||||
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
|
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_clients_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
save_client_impl(&conn, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
delete_client_impl(&conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::open_in_memory;
|
||||||
|
|
||||||
|
fn blank(name: &str) -> Client {
|
||||||
|
serde_json::from_value(serde_json::json!({ "id": null, "name": name })).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_address_orders_parts() {
|
||||||
|
assert_eq!(
|
||||||
|
compose_address("12 MG Road", "Floor 2", "Bengaluru", "29", "560001"),
|
||||||
|
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
|
||||||
|
);
|
||||||
|
assert_eq!(compose_address("", "", "", "", ""), "");
|
||||||
|
assert_eq!(compose_address(" A ", "", "", "", "560001"), "A\n560001");
|
||||||
|
assert_eq!(compose_address("", "", "Pune", "", ""), "Pune");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_round_trip_composes_address() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let mut c = blank("Acme");
|
||||||
|
c.address_line1 = "12 MG Road".into();
|
||||||
|
c.address_line2 = "Floor 2".into();
|
||||||
|
c.city = "Bengaluru".into();
|
||||||
|
c.pincode = "560001".into();
|
||||||
|
c.state_code = "29".into();
|
||||||
|
c.gstin = "29abcde1234f1zw".into();
|
||||||
|
c.gst_category = "registered_regular".into();
|
||||||
|
c.default_notes = "Net 15".into();
|
||||||
|
c.payment_terms_days = Some(15);
|
||||||
|
c.po_number = "PO-9".into();
|
||||||
|
let saved = save_client_impl(&conn, c).unwrap();
|
||||||
|
assert!(saved.id.is_some());
|
||||||
|
assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
||||||
|
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
|
||||||
|
let listed = list_clients_impl(&conn).unwrap();
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
let l = &listed[0];
|
||||||
|
assert_eq!(l.address_line1, "12 MG Road");
|
||||||
|
assert_eq!(l.gst_category, "registered_regular");
|
||||||
|
assert_eq!(l.default_notes, "Net 15");
|
||||||
|
assert_eq!(l.payment_terms_days, Some(15));
|
||||||
|
assert_eq!(l.invoice_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_address_is_kept_when_no_structured_parts() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let mut c = blank("Legacy");
|
||||||
|
c.address = "Old style\nfree text".into();
|
||||||
|
let saved = save_client_impl(&conn, c).unwrap();
|
||||||
|
assert_eq!(saved.address, "Old style\nfree text");
|
||||||
|
assert_eq!(saved.gst_category, "unregistered");
|
||||||
|
// Editing a field other than the address leaves it alone.
|
||||||
|
let mut edit = saved.clone();
|
||||||
|
edit.po_number = "PO-1".into();
|
||||||
|
let again = save_client_impl(&conn, edit).unwrap();
|
||||||
|
assert_eq!(again.address, "Old style\nfree text");
|
||||||
|
assert_eq!(again.id, saved.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_changes_the_row_and_unknown_id_fails() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let saved = save_client_impl(&conn, blank("A")).unwrap();
|
||||||
|
let mut edit = saved.clone();
|
||||||
|
edit.name = "B".into();
|
||||||
|
edit.gst_category = "sez".into();
|
||||||
|
let again = save_client_impl(&conn, edit).unwrap();
|
||||||
|
assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez"));
|
||||||
|
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||||
|
let mut ghost = blank("Ghost");
|
||||||
|
ghost.id = Some(999);
|
||||||
|
assert!(save_client_impl(&conn, ghost).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_rejects_bad_input() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name"));
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
|
||||||
|
assert!(save_client_impl(&conn, c).unwrap_err().contains("GSTIN"));
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.gstin = "29ABCDE1234F1ZW".into();
|
||||||
|
c.state_code = "27".into();
|
||||||
|
assert!(save_client_impl(&conn, c).unwrap_err().contains("state"));
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.gstin = "NA".into();
|
||||||
|
assert_eq!(save_client_impl(&conn, c).unwrap().gstin, "");
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.state_code = "99".into();
|
||||||
|
assert!(save_client_impl(&conn, c).is_err());
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.gst_category = "weird".into();
|
||||||
|
assert!(save_client_impl(&conn, c).unwrap_err().contains("category"));
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.pincode = "56001".into();
|
||||||
|
assert!(save_client_impl(&conn, c).unwrap_err().contains("Pincode"));
|
||||||
|
|
||||||
|
let mut c = blank("X");
|
||||||
|
c.payment_terms_days = Some(400);
|
||||||
|
assert!(save_client_impl(&conn, c).is_err());
|
||||||
|
// Only the valid "NA" client above was stored; every rejected save left no row behind.
|
||||||
|
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_keeps_referencing_invoices_and_count_is_listed() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let saved = save_client_impl(&conn, blank("Referenced")).unwrap();
|
||||||
|
let id = saved.id.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, client_id, client_name, client_address, created_at, updated_at)
|
||||||
|
VALUES ('T-1', '2026-01-01', ?1, 'Referenced', 'Snap addr', 'now', 'now')",
|
||||||
|
params![id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(list_clients_impl(&conn).unwrap()[0].invoice_count, 1);
|
||||||
|
|
||||||
|
delete_client_impl(&conn, id).unwrap();
|
||||||
|
assert!(list_clients_impl(&conn).unwrap().is_empty());
|
||||||
|
let (name, addr): (String, String) = conn
|
||||||
|
.query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! 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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ use super::series::{validate_series_format, MAX_NUMBER_LEN};
|
|||||||
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
||||||
use crate::db::format_number;
|
use crate::db::format_number;
|
||||||
use crate::gst::{self, TaxType};
|
use crate::gst::{self, TaxType};
|
||||||
|
use super::payments::{derive_summary, summary_for, today_ist};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
|
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
|
||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
@@ -59,6 +60,8 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
|||||||
archived_fingerprint: row.get(35)?,
|
archived_fingerprint: row.get(35)?,
|
||||||
archived_at: row.get(36)?,
|
archived_at: row.get(36)?,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
|
payment: PaymentSummary::default(),
|
||||||
|
total_paise: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +93,8 @@ fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
|
|||||||
map_invoice,
|
map_invoice,
|
||||||
)?;
|
)?;
|
||||||
invoice.items = fetch_items(conn, id)?;
|
invoice.items = fetch_items(conn, id)?;
|
||||||
|
invoice.total_paise = gst::rupees_to_paise(invoice.total);
|
||||||
|
invoice.payment = summary_for(conn, id, &invoice.status, invoice.total, &invoice.due_date)?;
|
||||||
Ok(invoice)
|
Ok(invoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,15 +343,16 @@ pub fn issue_invoice_impl(
|
|||||||
Some(id) => Some(id),
|
Some(id) => Some(id),
|
||||||
None if input.save_client && !input.client_name.trim().is_empty() => {
|
None if input.save_client && !input.client_name.trim().is_empty() => {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at, gst_category)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
params![
|
params![
|
||||||
input.client_name,
|
input.client_name,
|
||||||
input.client_address,
|
input.client_address,
|
||||||
client_gstin,
|
client_gstin,
|
||||||
stored_pos,
|
stored_pos,
|
||||||
input.po_number,
|
input.po_number,
|
||||||
now
|
now,
|
||||||
|
if client_gstin.is_empty() { "unregistered" } else { "registered_regular" }
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
@@ -459,6 +465,12 @@ pub fn issue_invoice(
|
|||||||
|
|
||||||
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
||||||
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
|
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
|
||||||
|
let payments: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if payments > 0 {
|
||||||
|
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
|
||||||
|
}
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
let changed = conn
|
let changed = conn
|
||||||
.execute(
|
.execute(
|
||||||
@@ -490,23 +502,42 @@ pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
|
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_invoices_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One query: invoices left-joined to their payment sums. Status derivation happens in Rust
|
||||||
|
/// (`derive_summary`) so list and detail agree.
|
||||||
|
pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, String> {
|
||||||
|
let today = today_ist();
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, number, invoice_date, due_date, client_name, total, status, created_at
|
"SELECT i.id, i.number, i.invoice_date, i.due_date, i.client_name, i.total, i.status,
|
||||||
FROM invoices ORDER BY id DESC",
|
i.created_at, i.po_number, COALESCE(p.paid, 0), COALESCE(p.tds, 0)
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN (SELECT invoice_id, SUM(amount_paise) AS paid, SUM(tds_paise) AS tds
|
||||||
|
FROM payments GROUP BY invoice_id) p ON p.invoice_id = i.id
|
||||||
|
ORDER BY i.id DESC",
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
|
let total: f64 = row.get(5)?;
|
||||||
|
let status: String = row.get(6)?;
|
||||||
|
let due_date: String = row.get(3)?;
|
||||||
|
let total_paise = gst::rupees_to_paise(total);
|
||||||
|
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today);
|
||||||
Ok(InvoiceSummary {
|
Ok(InvoiceSummary {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
number: row.get(1)?,
|
number: row.get(1)?,
|
||||||
invoice_date: row.get(2)?,
|
invoice_date: row.get(2)?,
|
||||||
due_date: row.get(3)?,
|
due_date,
|
||||||
client_name: row.get(4)?,
|
client_name: row.get(4)?,
|
||||||
total: row.get(5)?,
|
total,
|
||||||
status: row.get(6)?,
|
status,
|
||||||
created_at: row.get(7)?,
|
created_at: row.get(7)?,
|
||||||
|
po_number: row.get(8)?,
|
||||||
|
total_paise,
|
||||||
|
payment,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
@@ -515,10 +546,91 @@ pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, Stri
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
|
||||||
|
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InvoiceLedgerRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub client_gstin: String,
|
||||||
|
pub place_of_supply_code: String,
|
||||||
|
pub doc_type: String,
|
||||||
|
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
|
||||||
|
pub hsn_sac: Vec<String>,
|
||||||
|
/// Subtotal less discount.
|
||||||
|
pub taxable_paise: i64,
|
||||||
|
pub cgst_paise: i64,
|
||||||
|
pub sgst_paise: i64,
|
||||||
|
pub igst_paise: i64,
|
||||||
|
pub reverse_charge: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||||
|
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
|
||||||
|
{
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let items = stmt
|
||||||
|
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
for item in items {
|
||||||
|
let (id, code) = item.map_err(|e| e.to_string())?;
|
||||||
|
let list = hsn_by_invoice.entry(id).or_default();
|
||||||
|
if !code.is_empty() && !list.contains(&code) {
|
||||||
|
list.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
|
||||||
|
cgst_amount, sgst_amount, igst_amount, reverse_charge
|
||||||
|
FROM invoices ORDER BY id DESC",
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
let id: i64 = r.get(0)?;
|
||||||
|
Ok(InvoiceLedgerRow {
|
||||||
|
id,
|
||||||
|
client_gstin: r.get(1)?,
|
||||||
|
place_of_supply_code: r.get(2)?,
|
||||||
|
doc_type: r.get(3)?,
|
||||||
|
hsn_sac: Vec::new(),
|
||||||
|
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
|
||||||
|
cgst_paise: gst::rupees_to_paise(r.get(6)?),
|
||||||
|
sgst_paise: gst::rupees_to_paise(r.get(7)?),
|
||||||
|
igst_paise: gst::rupees_to_paise(r.get(8)?),
|
||||||
|
reverse_charge: r.get::<_, i64>(9)? != 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut row| {
|
||||||
|
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_invoice_ledger_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
|
||||||
|
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
fetch_invoice(&conn, id).map_err(|e| e.to_string())
|
get_invoice_impl(&conn, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_draft_impl(
|
pub fn save_draft_impl(
|
||||||
@@ -661,6 +773,28 @@ mod tests {
|
|||||||
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
|
||||||
|
let mut conn = registered();
|
||||||
|
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
||||||
|
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
|
||||||
|
params![first.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ledger = list_invoice_ledger_impl(&conn).unwrap();
|
||||||
|
assert_eq!(ledger.len(), 1);
|
||||||
|
let row = &ledger[0];
|
||||||
|
assert_eq!(row.id, first.id);
|
||||||
|
assert_eq!(row.place_of_supply_code, "27");
|
||||||
|
assert_eq!(row.doc_type, "tax_invoice");
|
||||||
|
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
|
||||||
|
assert_eq!(row.taxable_paise, 731_000);
|
||||||
|
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
|
||||||
|
assert!(!row.reverse_charge);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn issues_numbered_tax_invoices_with_derived_totals() {
|
fn issues_numbered_tax_invoices_with_derived_totals() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
@@ -806,7 +940,7 @@ mod tests {
|
|||||||
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap();
|
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap();
|
||||||
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
|
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
|
||||||
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
|
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
|
||||||
assert_eq!(bank["bankName"], "State Bank of India");
|
assert_eq!(bank["bankName"], "Test Bank");
|
||||||
let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err();
|
let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err();
|
||||||
assert!(err.contains("bank account"), "{err}");
|
assert!(err.contains("bank account"), "{err}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
pub mod archive;
|
pub mod archive;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
pub mod backup;
|
||||||
pub mod clients;
|
pub mod clients;
|
||||||
|
pub mod erpnext;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod fonts;
|
pub mod fonts;
|
||||||
pub mod invoice;
|
pub mod invoice;
|
||||||
pub mod logo;
|
pub mod logo;
|
||||||
|
pub mod payments;
|
||||||
|
pub mod presets;
|
||||||
pub mod raw;
|
pub mod raw;
|
||||||
pub mod series;
|
pub mod series;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
//! 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,177 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
|||||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||||
|
|
||||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||||
const LATEST_VERSION: i64 = 6;
|
pub(crate) const LATEST_VERSION: i64 = 11;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DbError {
|
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;
|
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> {
|
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.
|
/// 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)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
/// Write a consistent, compacted snapshot of the open database to `target` (which must not exist yet).
|
||||||
|
/// Safe while other connections read and write: SQLite takes a read transaction for the copy.
|
||||||
|
pub(crate) fn vacuum_into(conn: &Connection, target: &Path) -> rusqlite::Result<()> {
|
||||||
|
let quoted = target.to_string_lossy().replace('\'', "''");
|
||||||
|
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
||||||
[],
|
[],
|
||||||
@@ -256,8 +403,7 @@ fn backup_before_migration(
|
|||||||
fs::create_dir_all(backup_dir)?;
|
fs::create_dir_all(backup_dir)?;
|
||||||
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||||
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
||||||
let quoted = target.to_string_lossy().replace('\'', "''");
|
vacuum_into(conn, &target)?;
|
||||||
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
|
|
||||||
// A failed prune must not block startup; the backup itself already succeeded.
|
// A failed prune must not block startup; the backup itself already succeeded.
|
||||||
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
||||||
Ok(target)
|
Ok(target)
|
||||||
@@ -290,8 +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.
|
/// Insert the first-run defaults the very first time the app is opened.
|
||||||
/// Vendor details are pre-filled from the owner's existing invoice. The vendor
|
/// The defaults are neutral: no vendor, bank or tax identity is pre-filled, so the setup wizard
|
||||||
/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax).
|
/// starts blank. The vendor is an unregistered supplier (no tax) until the wizard says otherwise.
|
||||||
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
let settings_count: i64 =
|
let settings_count: i64 =
|
||||||
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
||||||
@@ -301,38 +447,8 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
||||||
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
||||||
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
||||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
VALUES (1, '', '', '', '', '', '', '', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
||||||
params![
|
[],
|
||||||
"Test Vendor",
|
|
||||||
"1 Test Road, Bangalore - 560001",
|
|
||||||
"vendor@example.com",
|
|
||||||
"9000000000",
|
|
||||||
"ABCDE1234F",
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let bank_count: i64 =
|
|
||||||
conn.query_row("SELECT COUNT(*) FROM bank_accounts", [], |r| r.get(0))?;
|
|
||||||
if bank_count == 0 {
|
|
||||||
conn.execute(
|
|
||||||
r#"INSERT INTO bank_accounts
|
|
||||||
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)"#,
|
|
||||||
params![
|
|
||||||
"Primary",
|
|
||||||
"State Bank of India",
|
|
||||||
"Test Vendor",
|
|
||||||
"000000000000",
|
|
||||||
"000001",
|
|
||||||
"TEST0000001",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
let bank_id = conn.last_insert_rowid();
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
|
||||||
params![bank_id],
|
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +467,25 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sample vendor and bank for command-logic unit tests (production seeds none). Fake values only.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn seed_test_fixture(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
r#"UPDATE app_settings SET vendor_name = 'Test Vendor', vendor_address = '1 Test Road, Bangalore - 560001',
|
||||||
|
vendor_email = 'vendor@example.com', vendor_phone = '9000000000', vendor_pan = 'ABCDE1234F',
|
||||||
|
vendor_state_code = '29' WHERE id = 1"#,
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO bank_accounts (label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
||||||
|
VALUES ('Primary', 'Test Bank', 'Test Vendor', '000000000000', '000001', 'TEST0000001', 1)"#,
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
let bank_id = conn.last_insert_rowid();
|
||||||
|
conn.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![bank_id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// A migrated, seeded in-memory database for unit tests of the command logic.
|
/// A migrated, seeded in-memory database for unit tests of the command logic.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn open_in_memory() -> Result<Connection, DbError> {
|
pub fn open_in_memory() -> Result<Connection, DbError> {
|
||||||
@@ -358,6 +493,7 @@ pub fn open_in_memory() -> Result<Connection, DbError> {
|
|||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
migrations().to_latest(&mut conn)?;
|
migrations().to_latest(&mut conn)?;
|
||||||
seed(&conn)?;
|
seed(&conn)?;
|
||||||
|
seed_test_fixture(&conn)?;
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +716,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(tax_type, "none");
|
assert_eq!(tax_type, "none");
|
||||||
assert_eq!(gst_reg, "unregistered");
|
assert_eq!(gst_reg, "unregistered");
|
||||||
assert_eq!(name, "Test Vendor");
|
assert_eq!(name, "");
|
||||||
assert!(backups_in(&backups).is_empty());
|
assert!(backups_in(&backups).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,6 +793,181 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
|
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m7_fresh_database_has_client_and_preset_columns() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
conn.prepare(
|
||||||
|
"SELECT address_line1, address_line2, city, pincode, gst_category, default_notes, payment_terms_days FROM clients",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at FROM item_presets")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO clients (name, created_at) VALUES ('X', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (cat, terms): (String, Option<i64>) = conn
|
||||||
|
.query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((cat.as_str(), terms), ("unregistered", None));
|
||||||
|
// The category CHECK rejects unknown values.
|
||||||
|
assert!(conn
|
||||||
|
.execute("UPDATE clients SET gst_category = 'bogus'", [])
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)];
|
||||||
|
Migrations::new(all).to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), 6);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||||
|
VALUES ('Acme', 'Line one\nLine two', '29ABCDE1234F1Z5', '29', 'PO-1', 'now'),
|
||||||
|
('Plain', '', '', '', '', 'now'),
|
||||||
|
('Na', '', 'NA', '', '', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
|
|
||||||
|
type Row = (String, String, String, String, String, String, String);
|
||||||
|
let rows: Vec<Row> = conn
|
||||||
|
.prepare("SELECT name, address, gstin, state_code, po_number, gst_category, address_line1 FROM clients ORDER BY id")
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.collect::<Result<_, _>>()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows[0].1, "Line one\nLine two");
|
||||||
|
assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1"));
|
||||||
|
assert_eq!(rows[0].5, "registered_regular");
|
||||||
|
assert_eq!(rows[0].6, "");
|
||||||
|
assert_eq!(rows[1].5, "unregistered");
|
||||||
|
assert_eq!(rows[2].5, "unregistered");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m10_fresh_database_has_the_erpnext_tables_and_a_default_config_row() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let (naming, submit, attach, auto_push, create_customers, secret, uom): (String, i64, i64, i64, i64, String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT naming_mode, submit_on_push, attach_pdf, auto_push_on_issue, create_missing_customers, api_secret, uom_map
|
||||||
|
FROM erpnext_config WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((naming.as_str(), submit, attach, auto_push, create_customers), ("mirror", 0, 1, 0, 1));
|
||||||
|
assert_eq!(secret, "");
|
||||||
|
assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok());
|
||||||
|
let rows: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!(rows, 1);
|
||||||
|
// Single row only.
|
||||||
|
assert!(conn.execute("INSERT INTO erpnext_config (id) VALUES (2)", []).is_err());
|
||||||
|
conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync")
|
||||||
|
.unwrap();
|
||||||
|
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients").unwrap();
|
||||||
|
conn.prepare("SELECT erpnext_payment_entry FROM payments").unwrap();
|
||||||
|
conn.prepare("SELECT erpnext_item_code FROM item_presets").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
let v9 = vec![
|
||||||
|
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9),
|
||||||
|
];
|
||||||
|
Migrations::new(v9).to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), 9);
|
||||||
|
conn.execute("INSERT INTO clients (name, gstin, created_at) VALUES ('Acme', '29ABCDE1234F1Z5', 'now')", []).unwrap();
|
||||||
|
conn.execute("INSERT INTO item_presets (description, rate_paise, created_at) VALUES ('Design', 500000, 'now')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO payments (invoice_id, paid_on, amount_paise, mode, created_at) VALUES (1, '2026-04-10', 12345, 'upi', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
|
|
||||||
|
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) = conn
|
||||||
|
.query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((name.as_str(), gstin.as_str(), customer, address), ("Acme", "29ABCDE1234F1Z5", None, None));
|
||||||
|
let (desc, rate, code): (String, i64, Option<String>) = conn
|
||||||
|
.query_row("SELECT description, rate_paise, erpnext_item_code FROM item_presets", [], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
|
||||||
|
let (paid, entry): (i64, Option<String>) = conn
|
||||||
|
.query_row("SELECT amount_paise, erpnext_payment_entry FROM payments", [], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((paid, entry), (12345, None));
|
||||||
|
let number: String = conn.query_row("SELECT number FROM invoices", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!(number, "INV/2026-001");
|
||||||
|
|
||||||
|
// The config row exists with defaults, and a sync row can reference the invoice.
|
||||||
|
let enabled: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config WHERE id = 1", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!(enabled, 1);
|
||||||
|
conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap();
|
||||||
|
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", []).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
let v10 = vec![
|
||||||
|
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), M::up(M10),
|
||||||
|
];
|
||||||
|
Migrations::new(v10).to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), 10);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO erpnext_sync (invoice_id, remote_name, remote_docstatus, status, payload_hash, synced_at)
|
||||||
|
VALUES (1, 'INV/2026-001', 1, 'synced', 'abc', '2026-04-02T00:00:00Z')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err());
|
||||||
|
|
||||||
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), 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) {
|
fn build_v1_database(path: &Path) {
|
||||||
let conn = Connection::open(path).unwrap();
|
let conn = Connection::open(path).unwrap();
|
||||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub const STATES: &[(&str, &str)] = &[
|
|||||||
("09", "Uttar Pradesh"),
|
("09", "Uttar Pradesh"),
|
||||||
("10", "Bihar"),
|
("10", "Bihar"),
|
||||||
("11", "Sikkim"),
|
("11", "Sikkim"),
|
||||||
("12", "Sampleachal Pradesh"),
|
("12", "Arunachal Pradesh"),
|
||||||
("13", "Nagaland"),
|
("13", "Nagaland"),
|
||||||
("14", "Manipur"),
|
("14", "Manipur"),
|
||||||
("15", "Mizoram"),
|
("15", "Mizoram"),
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
//! 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,562 @@
|
|||||||
|
//! 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,861 @@
|
|||||||
|
//! What the connected site is (versions, India Compliance), the lists the settings dropdowns need, and the
|
||||||
|
//! connection test that turns all of it into warnings.
|
||||||
|
|
||||||
|
use super::client::ErpClient;
|
||||||
|
use super::config::{ErpnextConfig, NamingMode};
|
||||||
|
use super::errors::{ErpError, ErrorKind};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
// ---- versions and feature switches ----
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||||
|
pub struct Version {
|
||||||
|
pub major: u32,
|
||||||
|
pub minor: u32,
|
||||||
|
pub patch: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Version {
|
||||||
|
/// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored.
|
||||||
|
pub fn parse(s: &str) -> Option<Version> {
|
||||||
|
let s = s.trim().trim_start_matches(['v', 'V']);
|
||||||
|
let numeric: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
|
||||||
|
let mut parts = numeric.split('.').filter(|p| !p.is_empty());
|
||||||
|
let major = parts.next()?.parse().ok()?;
|
||||||
|
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
|
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
|
Some(Version { major, minor, patch })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ServerVersions {
|
||||||
|
pub frappe: Option<String>,
|
||||||
|
pub erpnext: Option<String>,
|
||||||
|
pub india_compliance: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FeatureSwitches {
|
||||||
|
/// API v2 keeps a posted document name verbatim (Frappe >= 15.73, or 16+).
|
||||||
|
pub v2_naming: bool,
|
||||||
|
/// India Compliance is installed, so the GST header and item fields apply.
|
||||||
|
pub india_compliance: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn feature_switches(v: &ServerVersions) -> FeatureSwitches {
|
||||||
|
let frappe = v.frappe.as_deref().and_then(Version::parse);
|
||||||
|
FeatureSwitches {
|
||||||
|
v2_naming: frappe.is_some_and(|f| (f.major, f.minor) >= (15, 73)),
|
||||||
|
india_compliance: v.india_compliance.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `{"message": {"frappe": {"version": "15.73.0"}, ...}}`.
|
||||||
|
pub fn parse_versions(value: &Value) -> ServerVersions {
|
||||||
|
let apps = value.get("message").unwrap_or(value);
|
||||||
|
let version_of = |app: &str| -> Option<String> {
|
||||||
|
let entry = apps.get(app)?;
|
||||||
|
let v = entry.get("version").and_then(Value::as_str).or_else(|| entry.as_str())?;
|
||||||
|
let v = v.trim();
|
||||||
|
(!v.is_empty()).then(|| v.to_string())
|
||||||
|
};
|
||||||
|
ServerVersions {
|
||||||
|
frappe: version_of("frappe"),
|
||||||
|
erpnext: version_of("erpnext"),
|
||||||
|
india_compliance: version_of("india_compliance"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- number rules (India Compliance) ----
|
||||||
|
|
||||||
|
pub const IC_MAX_NUMBER_LEN: usize = 16;
|
||||||
|
|
||||||
|
/// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`.
|
||||||
|
pub fn ic_number_ok(number: &str) -> bool {
|
||||||
|
let mut chars = number.chars();
|
||||||
|
let Some(first) = chars.next() else { return false };
|
||||||
|
first.is_alphanumeric()
|
||||||
|
&& number.chars().count() <= IC_MAX_NUMBER_LEN
|
||||||
|
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Length of a number generated from a Frappe naming series such as `SINV-.YY.-.#####`. A series with no
|
||||||
|
/// `#` run gets Frappe's default five digits.
|
||||||
|
pub fn naming_series_expanded_len(series: &str) -> usize {
|
||||||
|
let mut len = 0;
|
||||||
|
let mut has_digits = false;
|
||||||
|
for part in series.split('.') {
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if part.chars().all(|c| c == '#') {
|
||||||
|
len += part.len();
|
||||||
|
has_digits = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
len += match part {
|
||||||
|
"YY" | "MM" | "DD" => 2,
|
||||||
|
"YYYY" => 4,
|
||||||
|
"FY" => 7,
|
||||||
|
literal => literal.chars().count(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if !has_digits {
|
||||||
|
len += 5;
|
||||||
|
}
|
||||||
|
len
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- options for the settings dropdowns ----
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OptionItem {
|
||||||
|
/// The value to store (the document name).
|
||||||
|
pub name: String,
|
||||||
|
pub label: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct OptionError {
|
||||||
|
pub list: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ErpnextOptions {
|
||||||
|
pub companies: Vec<OptionItem>,
|
||||||
|
pub company_addresses: Vec<OptionItem>,
|
||||||
|
pub income_accounts: Vec<OptionItem>,
|
||||||
|
pub tax_accounts: Vec<OptionItem>,
|
||||||
|
pub tax_templates: Vec<OptionItem>,
|
||||||
|
pub naming_series: Vec<OptionItem>,
|
||||||
|
pub item_groups: Vec<OptionItem>,
|
||||||
|
pub uoms: Vec<OptionItem>,
|
||||||
|
pub cost_centers: Vec<OptionItem>,
|
||||||
|
pub price_lists: Vec<OptionItem>,
|
||||||
|
pub customer_groups: Vec<OptionItem>,
|
||||||
|
pub territories: Vec<OptionItem>,
|
||||||
|
/// Lists that could not be loaded (one failing list does not hide the rest).
|
||||||
|
pub errors: Vec<OptionError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text<'a>(row: &'a Value, key: &str) -> &'a str {
|
||||||
|
row.get(key).and_then(Value::as_str).unwrap_or("").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item(row: &Value, label_key: &str, detail: String) -> Option<OptionItem> {
|
||||||
|
let name = text(row, "name");
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let label = text(row, label_key);
|
||||||
|
Some(OptionItem {
|
||||||
|
name: name.to_string(),
|
||||||
|
label: if label.is_empty() { name.to_string() } else { label.to_string() },
|
||||||
|
detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join(parts: &[&str]) -> String {
|
||||||
|
parts.iter().filter(|p| !p.is_empty()).cloned().collect::<Vec<_>>().join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank.
|
||||||
|
pub fn parse_naming_series(doctype_response: &Value) -> Vec<OptionItem> {
|
||||||
|
let docs = doctype_response
|
||||||
|
.get("docs")
|
||||||
|
.or_else(|| doctype_response.get("message").and_then(|m| m.get("docs")))
|
||||||
|
.and_then(Value::as_array);
|
||||||
|
let Some(doc) = docs.and_then(|d| d.first()) else { return Vec::new() };
|
||||||
|
let Some(fields) = doc.get("fields").and_then(Value::as_array) else { return Vec::new() };
|
||||||
|
let Some(field) = fields.iter().find(|f| text(f, "fieldname") == "naming_series") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
text(field, "options")
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.map(|l| OptionItem { name: l.to_string(), label: l.to_string(), detail: format!("e.g. {} digits", naming_series_expanded_len(l)) })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErpClient {
|
||||||
|
pub async fn get_logged_user(&self) -> Result<String, ErpError> {
|
||||||
|
let v = self.get(&["api", "method", "frappe.auth.get_logged_user"], &[]).await?;
|
||||||
|
let user = v.get("message").and_then(Value::as_str).unwrap_or("").trim().to_string();
|
||||||
|
if user.is_empty() || user.eq_ignore_ascii_case("guest") {
|
||||||
|
return Err(ErpError {
|
||||||
|
kind: ErrorKind::Auth,
|
||||||
|
status: None,
|
||||||
|
message: "The API key and secret were not accepted (the site treated the request as Guest).".into(),
|
||||||
|
exc_type: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_versions(&self) -> Result<ServerVersions, ErpError> {
|
||||||
|
let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?;
|
||||||
|
Ok(parse_versions(&v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/resource/<doctype>` with every row (`limit_page_length=0`).
|
||||||
|
pub async fn list_resource(
|
||||||
|
&self,
|
||||||
|
doctype: &str,
|
||||||
|
fields: &[&str],
|
||||||
|
filters: Value,
|
||||||
|
order_by: &str,
|
||||||
|
) -> Result<Vec<Value>, ErpError> {
|
||||||
|
let mut query = vec![
|
||||||
|
("fields", json!(fields).to_string()),
|
||||||
|
("limit_page_length", "0".to_string()),
|
||||||
|
("order_by", order_by.to_string()),
|
||||||
|
];
|
||||||
|
if !filters.is_null() {
|
||||||
|
query.push(("filters", filters.to_string()));
|
||||||
|
}
|
||||||
|
let v = self.get(&["api", "resource", doctype], &query).await?;
|
||||||
|
Ok(v.get("data").and_then(Value::as_array).cloned().unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_options(&self, company: &str) -> Result<ErpnextOptions, ErpError> {
|
||||||
|
let mut out = ErpnextOptions::default();
|
||||||
|
let company = company.trim();
|
||||||
|
|
||||||
|
macro_rules! load {
|
||||||
|
($list:literal, $target:expr, $call:expr) => {
|
||||||
|
match $call.await {
|
||||||
|
Ok(rows) => $target = rows,
|
||||||
|
Err(e) if matches!(e.kind, ErrorKind::Auth | ErrorKind::Network | ErrorKind::Timeout | ErrorKind::Config | ErrorKind::Protocol) => {
|
||||||
|
return Err(e)
|
||||||
|
}
|
||||||
|
Err(e) => out.errors.push(OptionError { list: $list.to_string(), message: e.to_string() }),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
load!("companies", out.companies, async {
|
||||||
|
self.list_resource("Company", &["name", "company_name", "abbr", "country", "default_currency"], Value::Null, "name asc")
|
||||||
|
.await
|
||||||
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "company_name", join(&[text(r, "abbr"), text(r, "default_currency")])))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
load!("itemGroups", out.item_groups, async {
|
||||||
|
self.list_resource("Item Group", &["name"], Value::Null, "name asc")
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
||||||
|
});
|
||||||
|
load!("uoms", out.uoms, async {
|
||||||
|
self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc")
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
||||||
|
});
|
||||||
|
load!("priceLists", out.price_lists, async {
|
||||||
|
self.list_resource("Price List", &["name"], json!([["enabled", "=", 1], ["selling", "=", 1]]), "name asc")
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
||||||
|
});
|
||||||
|
load!("customerGroups", out.customer_groups, async {
|
||||||
|
self.list_resource("Customer Group", &["name"], json!([["is_group", "=", 0]]), "name asc")
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
||||||
|
});
|
||||||
|
load!("territories", out.territories, async {
|
||||||
|
self.list_resource("Territory", &["name"], json!([["is_group", "=", 0]]), "name asc")
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
||||||
|
});
|
||||||
|
load!("namingSeries", out.naming_series, async {
|
||||||
|
self.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Sales Invoice".to_string())])
|
||||||
|
.await
|
||||||
|
.map(|v| parse_naming_series(&v))
|
||||||
|
});
|
||||||
|
|
||||||
|
if company.is_empty() {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
load!("companyAddresses", out.company_addresses, async {
|
||||||
|
let filters = json!([
|
||||||
|
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||||
|
["Dynamic Link", "link_name", "=", company],
|
||||||
|
["disabled", "=", 0]
|
||||||
|
]);
|
||||||
|
let base = ["name", "address_title", "address_line1", "city", "state", "pincode"];
|
||||||
|
let with_gstin = ["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"];
|
||||||
|
// `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted").
|
||||||
|
let rows = match self.list_resource("Address", &with_gstin, filters.clone(), "name asc").await {
|
||||||
|
Err(e) if e.kind == ErrorKind::Validation => self.list_resource("Address", &base, filters, "name asc").await?,
|
||||||
|
other => other?,
|
||||||
|
};
|
||||||
|
Ok::<_, ErpError>(
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| {
|
||||||
|
item(
|
||||||
|
r,
|
||||||
|
"address_title",
|
||||||
|
join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
load!("incomeAccounts", out.income_accounts, async {
|
||||||
|
self.list_resource(
|
||||||
|
"Account",
|
||||||
|
&["name", "account_name", "account_type"],
|
||||||
|
json!([["company", "=", company], ["root_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect())
|
||||||
|
});
|
||||||
|
load!("taxAccounts", out.tax_accounts, async {
|
||||||
|
self.list_resource(
|
||||||
|
"Account",
|
||||||
|
&["name", "account_name", "account_type"],
|
||||||
|
json!([["company", "=", company], ["account_type", "=", "Tax"], ["is_group", "=", 0], ["disabled", "=", 0]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect())
|
||||||
|
});
|
||||||
|
load!("taxTemplates", out.tax_templates, async {
|
||||||
|
self.list_resource(
|
||||||
|
"Sales Taxes and Charges Template",
|
||||||
|
&["name", "title", "is_default"],
|
||||||
|
json!([["company", "=", company], ["disabled", "=", 0]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().filter_map(|r| item(r, "title", String::new())).collect())
|
||||||
|
});
|
||||||
|
load!("costCenters", out.cost_centers, async {
|
||||||
|
self.list_resource(
|
||||||
|
"Cost Center",
|
||||||
|
&["name", "cost_center_name"],
|
||||||
|
json!([["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().filter_map(|r| item(r, "cost_center_name", String::new())).collect())
|
||||||
|
});
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- connection test and warnings ----
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct Warning {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn(code: &str, message: impl Into<String>) -> Warning {
|
||||||
|
Warning { code: code.to_string(), message: message.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Facts from the local database that the warnings need.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct LocalFacts {
|
||||||
|
pub vendor_registered: bool,
|
||||||
|
/// Numbers of issued/cancelled invoices that India Compliance would refuse (at most a few examples).
|
||||||
|
pub invalid_numbers: Vec<String>,
|
||||||
|
/// The number the next invoice would get, if a series is active.
|
||||||
|
pub next_number: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What was learned from the site. `None` means the check could not run.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RemoteFacts {
|
||||||
|
pub versions: Option<ServerVersions>,
|
||||||
|
pub company_address_count: Option<usize>,
|
||||||
|
/// Rows of GST Settings -> gst_accounts, or the reason they could not be read.
|
||||||
|
pub gst_accounts: Option<Result<Vec<Value>, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks that the configured tax accounts appear as Output accounts for the company in GST Settings.
|
||||||
|
/// Otherwise India Compliance silently files the items as Nil-Rated.
|
||||||
|
pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
|
||||||
|
let for_company: Vec<&Value> = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output"))
|
||||||
|
.collect();
|
||||||
|
if for_company.is_empty() {
|
||||||
|
return vec![warn(
|
||||||
|
"gst_accounts_not_configured",
|
||||||
|
format!(
|
||||||
|
"GST accounts are not configured for \"{}\" in GST Settings. India Compliance will file these invoices as Nil-Rated.",
|
||||||
|
cfg.company
|
||||||
|
),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
let has = |field: &str, account: &str| for_company.iter().any(|r| text(r, field) == account);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (field, label, account) in [
|
||||||
|
("cgst_account", "CGST", cfg.cgst_account.as_str()),
|
||||||
|
("sgst_account", "SGST", cfg.sgst_account.as_str()),
|
||||||
|
("igst_account", "IGST", cfg.igst_account.as_str()),
|
||||||
|
] {
|
||||||
|
if !account.is_empty() && !has(field, account) {
|
||||||
|
out.push(warn(
|
||||||
|
"gst_account_mismatch",
|
||||||
|
format!("The {label} account \"{account}\" is not the {label} output account in GST Settings; GST returns would not pick the tax up."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure: turns the configuration and the facts gathered into the list shown after a connection test.
|
||||||
|
pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFacts) -> Vec<Warning> {
|
||||||
|
let mut w = Vec::new();
|
||||||
|
let features = remote.versions.as_ref().map(feature_switches);
|
||||||
|
let ic = features.is_some_and(|f| f.india_compliance);
|
||||||
|
|
||||||
|
if remote.versions.is_none() {
|
||||||
|
w.push(warn(
|
||||||
|
"versions_unknown",
|
||||||
|
"The site's versions could not be read, so India Compliance and API v2 naming were not detected.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if cfg.company.is_empty() {
|
||||||
|
w.push(warn("company_missing", "No company is selected."));
|
||||||
|
}
|
||||||
|
if cfg.income_account.is_empty() {
|
||||||
|
w.push(warn(
|
||||||
|
"income_account_missing",
|
||||||
|
"No income account is set. Rows without an item code need one, and ERPNext has no fallback for them.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if cfg.naming_mode == NamingMode::Mirror {
|
||||||
|
if let Some(f) = features {
|
||||||
|
if !f.v2_naming {
|
||||||
|
w.push(warn(
|
||||||
|
"mirror_unsupported",
|
||||||
|
"Mirroring the Voiced number needs Frappe 15.73 or newer. Use the ERPNext series naming mode instead.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if cfg.naming_series.is_empty() {
|
||||||
|
w.push(warn("naming_series_missing", "ERPNext series naming is selected but no naming series is chosen."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if local.vendor_registered {
|
||||||
|
if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty() {
|
||||||
|
w.push(warn(
|
||||||
|
"tax_accounts_missing",
|
||||||
|
"The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if remote.versions.is_some() && !ic {
|
||||||
|
w.push(warn(
|
||||||
|
"ic_not_detected",
|
||||||
|
"India Compliance is not installed on the site, so the GST fields (place of supply, HSN, GSTIN) will not be sent.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ic {
|
||||||
|
if local.vendor_registered {
|
||||||
|
match &remote.gst_accounts {
|
||||||
|
Some(Ok(rows)) => w.extend(check_gst_accounts(rows, cfg)),
|
||||||
|
Some(Err(why)) => w.push(warn(
|
||||||
|
"gst_settings_unreadable",
|
||||||
|
format!("GST Settings could not be read ({why}), so the GST accounts were not checked."),
|
||||||
|
)),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.company_address.is_empty() {
|
||||||
|
w.push(warn("company_address_missing", "India Compliance needs a company address on every invoice; none is selected."));
|
||||||
|
} else if remote.company_address_count == Some(0) {
|
||||||
|
w.push(warn("company_address_missing", "No address is linked to the company on the site."));
|
||||||
|
}
|
||||||
|
match cfg.naming_mode {
|
||||||
|
NamingMode::Mirror => {
|
||||||
|
if !local.invalid_numbers.is_empty() {
|
||||||
|
w.push(warn(
|
||||||
|
"number_invalid",
|
||||||
|
format!(
|
||||||
|
"India Compliance allows at most {IC_MAX_NUMBER_LEN} characters (letters, digits, - and /). These numbers do not qualify: {}.",
|
||||||
|
local.invalid_numbers.join(", ")
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(next) = &local.next_number {
|
||||||
|
if !ic_number_ok(next) {
|
||||||
|
w.push(warn(
|
||||||
|
"number_invalid",
|
||||||
|
format!("The next invoice number {next} is longer than {IC_MAX_NUMBER_LEN} characters or has characters India Compliance refuses."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NamingMode::Series => {
|
||||||
|
let len = naming_series_expanded_len(&cfg.naming_series);
|
||||||
|
if !cfg.naming_series.is_empty() && len > IC_MAX_NUMBER_LEN {
|
||||||
|
w.push(warn(
|
||||||
|
"naming_series_too_long",
|
||||||
|
format!(
|
||||||
|
"The naming series \"{}\" produces numbers of about {len} characters; India Compliance allows {IC_MAX_NUMBER_LEN}.",
|
||||||
|
cfg.naming_series
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConnectionTest {
|
||||||
|
pub user: String,
|
||||||
|
pub versions: ServerVersions,
|
||||||
|
pub features: FeatureSwitches,
|
||||||
|
/// Same as `features.india_compliance`, for the UI.
|
||||||
|
pub india_compliance: bool,
|
||||||
|
pub warnings: Vec<Warning>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only
|
||||||
|
/// produce warnings.
|
||||||
|
pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &LocalFacts) -> Result<ConnectionTest, ErpError> {
|
||||||
|
let user = client.get_logged_user().await?;
|
||||||
|
let versions = match client.get_versions().await {
|
||||||
|
Ok(v) => Some(v),
|
||||||
|
Err(e) if e.kind == ErrorKind::Auth => return Err(e),
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
let mut remote = RemoteFacts { versions, ..Default::default() };
|
||||||
|
let features = remote.versions.as_ref().map(feature_switches);
|
||||||
|
if features.is_some_and(|f| f.india_compliance) {
|
||||||
|
if !cfg.company.is_empty() {
|
||||||
|
remote.company_address_count = client
|
||||||
|
.list_resource(
|
||||||
|
"Address",
|
||||||
|
&["name"],
|
||||||
|
json!([["Dynamic Link", "link_doctype", "=", "Company"], ["Dynamic Link", "link_name", "=", cfg.company], ["disabled", "=", 0]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|rows| rows.len());
|
||||||
|
}
|
||||||
|
if local.vendor_registered {
|
||||||
|
remote.gst_accounts = Some(
|
||||||
|
client
|
||||||
|
.get(&["api", "resource", "GST Settings", "GST Settings"], &[])
|
||||||
|
.await
|
||||||
|
.map(|v| v.get("data").and_then(|d| d.get("gst_accounts")).and_then(Value::as_array).cloned().unwrap_or_default())
|
||||||
|
.map_err(|e| e.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let warnings = build_warnings(cfg, local, &remote);
|
||||||
|
let versions = remote.versions.unwrap_or_default();
|
||||||
|
let features = feature_switches(&versions);
|
||||||
|
Ok(ConnectionTest { user, india_compliance: features.india_compliance, features, versions, warnings })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn cfg() -> ErpnextConfig {
|
||||||
|
ErpnextConfig {
|
||||||
|
company: "Test Co".into(),
|
||||||
|
company_address: "Test Co-Billing".into(),
|
||||||
|
income_account: "Sales - AC".into(),
|
||||||
|
cgst_account: "Output CGST - AC".into(),
|
||||||
|
sgst_account: "Output SGST - AC".into(),
|
||||||
|
igst_account: "Output IGST - AC".into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ic_versions(frappe: &str) -> RemoteFacts {
|
||||||
|
RemoteFacts {
|
||||||
|
versions: Some(ServerVersions {
|
||||||
|
frappe: Some(frappe.into()),
|
||||||
|
erpnext: Some("15.121.6".into()),
|
||||||
|
india_compliance: Some("15.0.0".into()),
|
||||||
|
}),
|
||||||
|
company_address_count: Some(1),
|
||||||
|
gst_accounts: Some(Ok(vec![json!({
|
||||||
|
"company": "Test Co", "account_type": "Output",
|
||||||
|
"cgst_account": "Output CGST - AC", "sgst_account": "Output SGST - AC", "igst_account": "Output IGST - AC"
|
||||||
|
})])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codes(w: &[Warning]) -> Vec<&str> {
|
||||||
|
w.iter().map(|w| w.code.as_str()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn versions_parse_leniently() {
|
||||||
|
assert_eq!(Version::parse("15.73.2"), Some(Version { major: 15, minor: 73, patch: 2 }));
|
||||||
|
assert_eq!(Version::parse("v16.0.0-dev"), Some(Version { major: 16, minor: 0, patch: 0 }));
|
||||||
|
assert_eq!(Version::parse("15.1"), Some(Version { major: 15, minor: 1, patch: 0 }));
|
||||||
|
assert_eq!(Version::parse("15.73.0-beta.2"), Some(Version { major: 15, minor: 73, patch: 0 }));
|
||||||
|
assert_eq!(Version::parse("unknown"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn feature_switches_follow_the_frappe_version_and_ic_presence() {
|
||||||
|
let v = |frappe: &str, ic: bool| ServerVersions {
|
||||||
|
frappe: Some(frappe.into()),
|
||||||
|
erpnext: None,
|
||||||
|
india_compliance: ic.then(|| "15.0.0".into()),
|
||||||
|
};
|
||||||
|
assert!(!feature_switches(&v("15.72.9", false)).v2_naming);
|
||||||
|
assert!(feature_switches(&v("15.73.0", false)).v2_naming);
|
||||||
|
assert!(feature_switches(&v("16.0.0-dev", true)).v2_naming);
|
||||||
|
assert!(feature_switches(&v("16.0.0-dev", true)).india_compliance);
|
||||||
|
assert!(!feature_switches(&ServerVersions::default()).v2_naming);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_versions_response_is_parsed() {
|
||||||
|
let v = parse_versions(&json!({ "message": {
|
||||||
|
"frappe": { "title": "Frappe Framework", "version": "15.73.0" },
|
||||||
|
"erpnext": { "version": "15.121.6" },
|
||||||
|
"india_compliance": { "version": "15.9.0" },
|
||||||
|
"hrms": { "version": "15.0.0" },
|
||||||
|
}}));
|
||||||
|
assert_eq!(v.frappe.as_deref(), Some("15.73.0"));
|
||||||
|
assert_eq!(v.erpnext.as_deref(), Some("15.121.6"));
|
||||||
|
assert_eq!(v.india_compliance.as_deref(), Some("15.9.0"));
|
||||||
|
assert!(parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } })).india_compliance.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn india_compliance_number_rule() {
|
||||||
|
for ok in ["INV/2026-001", "A", "1234567890123456", "AP-24-25/0001"] {
|
||||||
|
assert!(ic_number_ok(ok), "{ok}");
|
||||||
|
}
|
||||||
|
for bad in ["", "12345678901234567", "_AB", "AB 01", "AB_01", "-AB", "/AB", "AB#1"] {
|
||||||
|
assert!(!ic_number_ok(bad), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn naming_series_is_expanded_before_measuring() {
|
||||||
|
assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5);
|
||||||
|
assert_eq!(naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"), 9 + 4 + 1 + 5);
|
||||||
|
assert_eq!(naming_series_expanded_len("INV-.MM.-.DD.-.####"), 4 + 2 + 1 + 2 + 1 + 4);
|
||||||
|
assert_eq!(naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"), 15 + 4 + 1 + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn naming_series_options_come_from_getdoctype() {
|
||||||
|
let resp = json!({ "docs": [{ "fields": [
|
||||||
|
{ "fieldname": "customer", "options": "Customer" },
|
||||||
|
{ "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" }
|
||||||
|
]}]});
|
||||||
|
let items = parse_naming_series(&resp);
|
||||||
|
assert_eq!(items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(), ["SINV-.YY.-", "ACC-SINV-.YYYY.-"]);
|
||||||
|
assert!(parse_naming_series(&json!({})).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_healthy_ic_setup_has_no_warnings() {
|
||||||
|
let local = LocalFacts { vendor_registered: true, invalid_numbers: vec![], next_number: Some("INV/2026-001".into()) };
|
||||||
|
assert_eq!(build_warnings(&cfg(), &local, &ic_versions("15.73.0")), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gst_accounts_not_configured_or_mismatched_are_flagged() {
|
||||||
|
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
||||||
|
let mut remote = ic_versions("15.73.0");
|
||||||
|
remote.gst_accounts = Some(Ok(vec![]));
|
||||||
|
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_accounts_not_configured"]);
|
||||||
|
|
||||||
|
let mut remote = ic_versions("15.73.0");
|
||||||
|
remote.gst_accounts = Some(Ok(vec![json!({ "company": "Test Co", "account_type": "Output",
|
||||||
|
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" })]));
|
||||||
|
let w = build_warnings(&cfg(), &local, &remote);
|
||||||
|
assert_eq!(codes(&w), ["gst_account_mismatch"]);
|
||||||
|
assert!(w[0].message.contains("SGST"));
|
||||||
|
|
||||||
|
let mut remote = ic_versions("15.73.0");
|
||||||
|
remote.gst_accounts = Some(Err("HTTP 403".into()));
|
||||||
|
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_settings_unreadable"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_numbers_and_series_are_flagged_under_ic_only() {
|
||||||
|
let local = LocalFacts {
|
||||||
|
vendor_registered: false,
|
||||||
|
invalid_numbers: vec!["INVOICE/2026/000001".into()],
|
||||||
|
next_number: Some("INVOICE/2026/000002".into()),
|
||||||
|
};
|
||||||
|
let w = build_warnings(&cfg(), &local, &ic_versions("15.73.0"));
|
||||||
|
assert_eq!(codes(&w), ["number_invalid", "number_invalid"]);
|
||||||
|
// Plain ERPNext has no such rule.
|
||||||
|
let mut plain = ic_versions("15.73.0");
|
||||||
|
plain.versions.as_mut().unwrap().india_compliance = None;
|
||||||
|
assert!(build_warnings(&cfg(), &local, &plain).is_empty());
|
||||||
|
|
||||||
|
let mut series = cfg();
|
||||||
|
series.naming_mode = NamingMode::Series;
|
||||||
|
series.naming_series = "LONGPREFIX-ABC-.YYYY.-.#####".into();
|
||||||
|
let w = build_warnings(&series, &LocalFacts::default(), &ic_versions("15.73.0"));
|
||||||
|
assert_eq!(codes(&w), ["naming_series_too_long"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_company_address_and_old_frappe_are_flagged() {
|
||||||
|
let mut c = cfg();
|
||||||
|
c.company_address.clear();
|
||||||
|
let w = build_warnings(&c, &LocalFacts::default(), &ic_versions("15.60.0"));
|
||||||
|
assert_eq!(codes(&w), ["mirror_unsupported", "company_address_missing"]);
|
||||||
|
|
||||||
|
let mut remote = ic_versions("15.73.0");
|
||||||
|
remote.company_address_count = Some(0);
|
||||||
|
assert_eq!(codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)), ["company_address_missing"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn required_settings_and_unknown_versions_are_flagged() {
|
||||||
|
let w = build_warnings(&ErpnextConfig::default(), &LocalFacts { vendor_registered: true, ..Default::default() }, &RemoteFacts::default());
|
||||||
|
assert_eq!(codes(&w), ["versions_unknown", "company_missing", "income_account_missing", "tax_accounts_missing"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registered_vendor_without_ic_gets_a_notice() {
|
||||||
|
let mut remote = ic_versions("15.73.0");
|
||||||
|
remote.versions.as_mut().unwrap().india_compliance = None;
|
||||||
|
let w = build_warnings(&cfg(), &LocalFacts { vendor_registered: true, ..Default::default() }, &remote);
|
||||||
|
assert_eq!(codes(&w), ["ic_not_detected"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- against the mock server ----
|
||||||
|
|
||||||
|
use crate::integrations::erpnext::testutil::{client, reply, serve_fn, Reply};
|
||||||
|
|
||||||
|
fn site(line: &str) -> Reply {
|
||||||
|
let path = line.split_whitespace().nth(1).unwrap_or("");
|
||||||
|
if path.contains("get_logged_user") {
|
||||||
|
reply(200, json!({ "message": "integration@example.com" }))
|
||||||
|
} else if path.contains("get_versions") {
|
||||||
|
reply(200, json!({ "message": {
|
||||||
|
"frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" }
|
||||||
|
}}))
|
||||||
|
} else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") {
|
||||||
|
reply(200, json!({ "data": { "gst_accounts": [] } }))
|
||||||
|
} else if path.starts_with("/api/resource/Address?") {
|
||||||
|
reply(200, json!({ "data": [
|
||||||
|
{ "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" }
|
||||||
|
]}))
|
||||||
|
} else if path.starts_with("/api/resource/Company?") {
|
||||||
|
reply(200, json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }))
|
||||||
|
} else if path.starts_with("/api/resource/Account?") && path.contains("Income") {
|
||||||
|
reply(200, json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] }))
|
||||||
|
} else if path.starts_with("/api/resource/Account?") && path.contains("Tax") {
|
||||||
|
reply(200, json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] }))
|
||||||
|
} else if path.starts_with("/api/resource/Price%20List?") {
|
||||||
|
reply(403, json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" }))
|
||||||
|
} else if path.contains("getdoctype") {
|
||||||
|
reply(200, json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] }))
|
||||||
|
} else if path.starts_with("/api/resource/UOM?") {
|
||||||
|
reply(200, json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] }))
|
||||||
|
} else {
|
||||||
|
reply(200, json!({ "data": [] }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connection_test_reports_user_versions_ic_and_warnings() {
|
||||||
|
let mock = serve_fn(|_, line| site(line)).await;
|
||||||
|
let local = LocalFacts { vendor_registered: true, next_number: Some("INV/2026-001".into()), ..Default::default() };
|
||||||
|
let result = test_connection(&client(&mock), &cfg(), &local).await.unwrap();
|
||||||
|
assert_eq!(result.user, "integration@example.com");
|
||||||
|
assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0"));
|
||||||
|
assert!(result.india_compliance && result.features.v2_naming);
|
||||||
|
assert_eq!(codes(&result.warnings), ["gst_accounts_not_configured"]);
|
||||||
|
let json = serde_json::to_value(&result).unwrap();
|
||||||
|
assert_eq!(json["indiaCompliance"], true);
|
||||||
|
assert_eq!(json["features"]["v2Naming"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn company_addresses_fall_back_when_the_site_has_no_gstin_field() {
|
||||||
|
// A plain ERPNext (no India Compliance) rejects the custom `gstin` field with a 417.
|
||||||
|
let mock = serve_fn(|_, line| {
|
||||||
|
let path = line.split_whitespace().nth(1).unwrap_or("");
|
||||||
|
if path.starts_with("/api/resource/Address?") && path.contains("gstin") {
|
||||||
|
reply(417, json!({ "exc_type": "ValidationError", "_error_message": "Field not permitted in query: gstin" }))
|
||||||
|
} else if path.starts_with("/api/resource/Address?") {
|
||||||
|
reply(200, json!({ "data": [{ "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001" }] }))
|
||||||
|
} else {
|
||||||
|
reply(200, json!({ "data": [] }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let options = client(&mock).load_options("Test Co").await.unwrap();
|
||||||
|
assert_eq!(options.company_addresses.len(), 1);
|
||||||
|
assert_eq!(options.company_addresses[0].name, "Test Co-Billing");
|
||||||
|
assert!(options.errors.iter().all(|e| e.list != "companyAddresses"), "{:?}", options.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
|
||||||
|
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;
|
||||||
|
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err();
|
||||||
|
assert_eq!(err.kind, ErrorKind::Auth);
|
||||||
|
|
||||||
|
let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await;
|
||||||
|
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err();
|
||||||
|
assert_eq!(err.kind, ErrorKind::Auth);
|
||||||
|
assert!(!err.to_string().contains("thesecret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn options_load_per_list_and_one_failure_does_not_hide_the_rest() {
|
||||||
|
let mock = serve_fn(|_, line| site(line)).await;
|
||||||
|
let options = client(&mock).load_options("Test Co").await.unwrap();
|
||||||
|
assert_eq!(options.companies.len(), 1);
|
||||||
|
assert_eq!(options.companies[0].name, "Test Co");
|
||||||
|
assert_eq!(options.companies[0].detail, "AC · INR");
|
||||||
|
assert_eq!(options.company_addresses[0].name, "Test Co-Billing");
|
||||||
|
assert!(options.company_addresses[0].detail.contains("Mumbai"));
|
||||||
|
assert_eq!(options.income_accounts[0].name, "Sales - AC");
|
||||||
|
assert_eq!(options.tax_accounts[0].name, "Output CGST - AC");
|
||||||
|
assert_eq!(options.naming_series[0].name, "SINV-.YY.-");
|
||||||
|
assert_eq!(options.uoms.iter().map(|u| u.name.as_str()).collect::<Vec<_>>(), ["Nos", "Hour"]);
|
||||||
|
assert!(options.price_lists.is_empty());
|
||||||
|
assert_eq!(options.errors.len(), 1);
|
||||||
|
assert_eq!(options.errors[0].list, "priceLists");
|
||||||
|
assert!(options.errors[0].message.contains("No permission for Price List"));
|
||||||
|
|
||||||
|
// Company-scoped lists are skipped until a company is chosen.
|
||||||
|
let none = client(&mock).load_options(" ").await.unwrap();
|
||||||
|
assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty());
|
||||||
|
let requests = mock.requests.lock().unwrap().join("\n");
|
||||||
|
assert!(requests.contains("root_type"), "income accounts are filtered by root type");
|
||||||
|
assert!(requests.contains("limit_page_length=0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn options_abort_on_authentication_failure() {
|
||||||
|
let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await;
|
||||||
|
let err = client(&mock).load_options("Test Co").await.unwrap_err();
|
||||||
|
assert_eq!(err.kind, ErrorKind::Auth);
|
||||||
|
assert_eq!(mock.hits.load(std::sync::atomic::Ordering::SeqCst), 1, "stops at the first auth failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
//! 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,755 @@
|
|||||||
|
//! Live tests against a real ERPNext site. All `#[ignore]`d: they need the stack from
|
||||||
|
//! `scripts/erpnext-e2e/README.md` and these environment variables (the setup script prints them):
|
||||||
|
//!
|
||||||
|
//! - `ERPNEXT_URL`, `ERPNEXT_KEY`, `ERPNEXT_SECRET`: the integration user's credentials;
|
||||||
|
//! - `ERPNEXT_COMPANY` (default `Voiced Test Co`) and `ERPNEXT_ABBR` (default `VTC`): the company and its
|
||||||
|
//! abbreviation, from which the account names (`Sales - VTC`, `Output Tax CGST - VTC` ...) are derived;
|
||||||
|
//! - `ERPNEXT_IC=1` when the site has India Compliance (enables the IC-only tests).
|
||||||
|
//!
|
||||||
|
//! Run: `cargo test --manifest-path src-tauri/Cargo.toml live_ -- --ignored --test-threads=1`.
|
||||||
|
//! Every test makes its own invoice series (unique prefix per run), so repeated runs never collide.
|
||||||
|
|
||||||
|
use super::client::{ClientOptions, ErpClient};
|
||||||
|
use super::config::{self, ErpnextConfig, NamingMode, Secret};
|
||||||
|
use super::errors::ErrorKind;
|
||||||
|
use super::discovery::{test_connection, ConnectionTest, LocalFacts};
|
||||||
|
use super::push::{push_invoice, push_payment, PushResult};
|
||||||
|
use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl};
|
||||||
|
use crate::commands::payments::{record_payment_impl, PaymentInput};
|
||||||
|
use crate::commands::series::start_new_series_impl;
|
||||||
|
use crate::models::{Invoice, InvoiceInput};
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
const VENDOR_GSTIN: &str = "27AAPFU0939F1ZV";
|
||||||
|
const KA_GSTIN: &str = "29AABCU9603R1ZJ";
|
||||||
|
|
||||||
|
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
fn env(name: &str) -> String {
|
||||||
|
std::env::var(name).unwrap_or_else(|_| panic!("set {name} (see scripts/erpnext-e2e/README.md)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_or(name: &str, default: &str) -> String {
|
||||||
|
std::env::var(name).unwrap_or_else(|_| default.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base36(mut n: u64, width: usize) -> String {
|
||||||
|
const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
||||||
|
let mut out = vec![b'0'; width];
|
||||||
|
for slot in out.iter_mut().rev() {
|
||||||
|
*slot = DIGITS[(n % 36) as usize];
|
||||||
|
n /= 36;
|
||||||
|
}
|
||||||
|
String::from_utf8(out).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Live {
|
||||||
|
db: Mutex<rusqlite::Connection>,
|
||||||
|
dir: tempfile::TempDir,
|
||||||
|
http: ErpClient,
|
||||||
|
abbr: String,
|
||||||
|
ic: bool,
|
||||||
|
prefix: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Live {
|
||||||
|
async fn new(edit: impl FnOnce(&mut ErpnextConfig)) -> Live {
|
||||||
|
let abbr = env_or("ERPNEXT_ABBR", "VTC");
|
||||||
|
let company = env_or("ERPNEXT_COMPANY", "Voiced Test Co");
|
||||||
|
let ic = env_or("ERPNEXT_IC", "0") == "1";
|
||||||
|
let mut cfg = ErpnextConfig {
|
||||||
|
base_url: env("ERPNEXT_URL"),
|
||||||
|
api_key: env("ERPNEXT_KEY"),
|
||||||
|
api_secret: Secret::new(env("ERPNEXT_SECRET")),
|
||||||
|
company_address: format!("{company}-Billing"),
|
||||||
|
company,
|
||||||
|
income_account: format!("Sales - {abbr}"),
|
||||||
|
cgst_account: format!("Output Tax CGST - {abbr}"),
|
||||||
|
sgst_account: format!("Output Tax SGST - {abbr}"),
|
||||||
|
utgst_account: format!("Output Tax UTGST - {abbr}"),
|
||||||
|
igst_account: format!("Output Tax IGST - {abbr}"),
|
||||||
|
cost_center: format!("Main - {abbr}"),
|
||||||
|
tds_account: format!("TDS Receivable - {abbr}"),
|
||||||
|
payment_bank_account: env_or("ERPNEXT_BANK_ACCOUNT", &format!("HDFC - {abbr}")),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
edit(&mut cfg);
|
||||||
|
let http = ErpClient::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, "", ClientOptions::default()).unwrap();
|
||||||
|
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET gst_registration = 'regular', vendor_gstin = ?1,
|
||||||
|
vendor_state_code = '27', vendor_pan = 'AAPFU0939F', default_tax_type = 'cgst_sgst'",
|
||||||
|
[VENDOR_GSTIN],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for (name, gstin, state, line1, city, pin, category) in [
|
||||||
|
("Voiced Live Intra Co", "", "27", "5 FC Road", "Pune", "411004", "unregistered"),
|
||||||
|
("Voiced Live Inter Co", KA_GSTIN, "29", "12 MG Road", "Bengaluru", "560001", "registered_regular"),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'now')",
|
||||||
|
params![name, line1, gstin, state, line1, city, pin, category],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() as u64;
|
||||||
|
let prefix = format!("L{}{}/", base36(nanos / 1_000_000, 4), base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1));
|
||||||
|
start_new_series_impl(&mut conn, &prefix, 3).unwrap();
|
||||||
|
config::save(&conn, &cfg).unwrap();
|
||||||
|
|
||||||
|
let live = Live { db: Mutex::new(conn), dir: tempfile::tempdir().unwrap(), http, abbr, ic, prefix };
|
||||||
|
// The same step the UI performs before the first push.
|
||||||
|
let ct = live.connection_test(&cfg).await;
|
||||||
|
let mut cfg = cfg;
|
||||||
|
cfg.last_detect_result = serde_json::to_string(&ct).unwrap();
|
||||||
|
config::save(&live.db.lock().unwrap(), &cfg).unwrap();
|
||||||
|
live
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest {
|
||||||
|
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
||||||
|
test_connection(&self.http, cfg, &local).await.expect("connection test")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cfg(&self) -> ErpnextConfig {
|
||||||
|
config::load(&self.db.lock().unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_cfg(&self, edit: impl FnOnce(&mut ErpnextConfig)) {
|
||||||
|
let mut cfg = self.cfg();
|
||||||
|
edit(&mut cfg);
|
||||||
|
config::save(&self.db.lock().unwrap(), &cfg).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preset(&self, description: &str, code: &str) {
|
||||||
|
self.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
|
||||||
|
VALUES (?1, '', 'fixed', 'unit', 0, 0, 'now', ?2)",
|
||||||
|
params![description, code],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issues an invoice for one of the two live clients. `extra` overrides input fields.
|
||||||
|
fn issue(&self, intra: bool, items: Value, extra: Value) -> Invoice {
|
||||||
|
let (client_id, name, pos, tax_type, gstin) = if intra {
|
||||||
|
(1, "Voiced Live Intra Co", "27", "cgst_sgst", "")
|
||||||
|
} else {
|
||||||
|
(2, "Voiced Live Inter Co", "29", "igst", KA_GSTIN)
|
||||||
|
};
|
||||||
|
let mut input = json!({
|
||||||
|
"invoiceDate": "2026-06-15",
|
||||||
|
"dueDate": "2026-07-15",
|
||||||
|
"clientId": client_id,
|
||||||
|
"clientName": name,
|
||||||
|
"clientGstin": gstin,
|
||||||
|
"placeOfSupplyStateCode": pos,
|
||||||
|
"taxType": tax_type,
|
||||||
|
"taxRate": 18.0,
|
||||||
|
"items": items,
|
||||||
|
});
|
||||||
|
for (k, v) in extra.as_object().unwrap() {
|
||||||
|
input[k] = v.clone();
|
||||||
|
}
|
||||||
|
let input: InvoiceInput = serde_json::from_value(input).unwrap();
|
||||||
|
let mut conn = self.db.lock().unwrap();
|
||||||
|
let inv = issue_invoice_impl(&mut conn, self.dir.path(), input, &json!({})).unwrap();
|
||||||
|
get_invoice_impl(&conn, inv.id).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a small PDF in the archive and records its hash, as exporting the invoice would.
|
||||||
|
fn archive(&self, inv: &Invoice) {
|
||||||
|
let bytes = minimal_pdf(&inv.number);
|
||||||
|
let sha = format!("{:x}", Sha256::digest(&bytes));
|
||||||
|
let dir = self.dir.path().join("archive");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join(format!("{sha}.pdf")), &bytes).unwrap();
|
||||||
|
self.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute("UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", params![sha, inv.id])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn push(&self, inv: &Invoice, submit: Option<bool>) -> PushResult {
|
||||||
|
push_invoice(&self.db, self.dir.path(), &self.http, inv.id, submit).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn doc(&self, doctype: &str, name: &str) -> Value {
|
||||||
|
let v = self.http.get(&["api", "resource", doctype, name], &[]).await.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}"));
|
||||||
|
v["data"].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoices_named(&self, number: &str) -> Vec<Value> {
|
||||||
|
self.http
|
||||||
|
.list_resource("Sales Invoice", &["name", "docstatus", "grand_total", "remarks"], json!([["remarks", "like", format!("%{number}%")]]), "creation asc")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn files_of(&self, doctype: &str, name: &str) -> Vec<Value> {
|
||||||
|
self.http
|
||||||
|
.list_resource(
|
||||||
|
"File",
|
||||||
|
&["name", "file_name", "is_private", "file_url"],
|
||||||
|
json!([["attached_to_doctype", "=", doctype], ["attached_to_name", "=", name]]),
|
||||||
|
"creation asc",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payment(&self, inv: &Invoice, cash: i64, tds: i64) -> i64 {
|
||||||
|
let mut conn = self.db.lock().unwrap();
|
||||||
|
record_payment_impl(
|
||||||
|
&mut conn,
|
||||||
|
PaymentInput {
|
||||||
|
invoice_id: inv.id,
|
||||||
|
paid_on: "2026-06-20".into(),
|
||||||
|
amount_paise: cash,
|
||||||
|
tds_paise: tds,
|
||||||
|
mode: "bank_transfer".into(),
|
||||||
|
reference: "UTR-LIVE-1".into(),
|
||||||
|
notes: String::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tiny but structurally valid one-page PDF; Frappe parses uploaded PDFs, so a stub header is rejected.
|
||||||
|
fn minimal_pdf(label: &str) -> Vec<u8> {
|
||||||
|
let stream = format!("BT /F1 12 Tf 20 100 Td ({label}) Tj ET");
|
||||||
|
let objects = [
|
||||||
|
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||||
|
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
|
||||||
|
format!("<< /Length {} >>\nstream\n{stream}\nendstream", stream.len()),
|
||||||
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||||
|
];
|
||||||
|
let mut out = b"%PDF-1.4\n".to_vec();
|
||||||
|
let mut offsets = Vec::new();
|
||||||
|
for (i, body) in objects.iter().enumerate() {
|
||||||
|
offsets.push(out.len());
|
||||||
|
out.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).into_bytes());
|
||||||
|
}
|
||||||
|
let xref = out.len();
|
||||||
|
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes());
|
||||||
|
for o in offsets {
|
||||||
|
out.extend(format!("{o:010} 00000 n \n").into_bytes());
|
||||||
|
}
|
||||||
|
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paise(v: &Value) -> i64 {
|
||||||
|
(v.as_f64().unwrap_or_else(|| panic!("not a number: {v}")) * 100.0).round() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_pushed(r: &PushResult) {
|
||||||
|
assert!(r.ok, "push failed: {:?} {:?}", r.error, r.warnings);
|
||||||
|
assert_eq!(r.status, "synced");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The invoice as ERPNext holds it: total equals Voiced's, tax rows are what Voiced computed.
|
||||||
|
async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
|
||||||
|
assert_eq!(paise(&doc["grand_total"]), crate::gst::rupees_to_paise(inv.total), "grand_total of {}", inv.number);
|
||||||
|
assert_eq!(doc["rounded_total"].as_f64().unwrap_or(0.0), 0.0, "rounding is disabled");
|
||||||
|
assert_eq!(doc["posting_date"], inv.invoice_date.as_str());
|
||||||
|
let taxes = doc["taxes"].as_array().unwrap();
|
||||||
|
let by_head = |needle: &str| -> Option<i64> {
|
||||||
|
taxes
|
||||||
|
.iter()
|
||||||
|
.find(|t| t["account_head"].as_str().unwrap_or("").contains(needle))
|
||||||
|
.map(|t| paise(&t["tax_amount"]))
|
||||||
|
};
|
||||||
|
assert_eq!(by_head("CGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.cgst_amount).max(0), "CGST of {}", inv.number);
|
||||||
|
// IGST rows contain "IGST"; "CGST" and "SGST" rows do not.
|
||||||
|
assert_eq!(by_head("SGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.sgst_amount), "SGST of {}", inv.number);
|
||||||
|
assert_eq!(by_head("IGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.igst_amount), "IGST of {}", inv.number);
|
||||||
|
let _ = live;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- connection and discovery ----
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
|
async fn live_connection_and_options() {
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let cfg = live.cfg();
|
||||||
|
let ct = live.connection_test(&cfg).await;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&ct).unwrap());
|
||||||
|
assert!(ct.user.contains('@'));
|
||||||
|
assert_eq!(ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")), Some(true));
|
||||||
|
assert!(ct.versions.erpnext.is_some());
|
||||||
|
assert_eq!(ct.india_compliance, live.ic);
|
||||||
|
assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming");
|
||||||
|
|
||||||
|
let o = live.http.load_options(&cfg.company).await.unwrap();
|
||||||
|
println!("option errors: {:?}", o.errors);
|
||||||
|
assert!(o.errors.is_empty(), "{:?}", o.errors);
|
||||||
|
let has = |list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
|
||||||
|
assert!(has(&o.companies, &cfg.company));
|
||||||
|
assert!(has(&o.income_accounts, &cfg.income_account), "{:?}", o.income_accounts);
|
||||||
|
assert!(o.tax_accounts.iter().any(|a| a.name.contains("CGST")), "{:?}", o.tax_accounts);
|
||||||
|
assert!(has(&o.uoms, "Hour") && has(&o.uoms, "Nos"));
|
||||||
|
assert!(!o.company_addresses.is_empty());
|
||||||
|
assert!(!o.naming_series.is_empty());
|
||||||
|
assert!(!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sales Invoice mapping ----
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
|
async fn live_push_matrix() {
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
live.preset("Preset service", "VOICED-SERVICE");
|
||||||
|
let hsn = |i: Value| {
|
||||||
|
let mut i = i;
|
||||||
|
if live.ic {
|
||||||
|
i["hsnSac"] = json!("998314");
|
||||||
|
}
|
||||||
|
i
|
||||||
|
};
|
||||||
|
let cases: Vec<(&str, Invoice)> = vec![
|
||||||
|
(
|
||||||
|
"cgst_sgst",
|
||||||
|
live.issue(true, json!([hsn(json!({"description": "Design", "mode": "fixed", "amount": 7310.0}))]), json!({})),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"igst, fractional hours",
|
||||||
|
live.issue(
|
||||||
|
false,
|
||||||
|
json!([hsn(json!({"description": "Consulting", "mode": "rate", "rate": 1234.56, "unit": "hour", "quantity": 2.5}))]),
|
||||||
|
json!({}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"discount, repeated description",
|
||||||
|
live.issue(
|
||||||
|
true,
|
||||||
|
json!([
|
||||||
|
hsn(json!({"description": "Logo", "mode": "fixed", "amount": 3000.0})),
|
||||||
|
hsn(json!({"description": "Logo", "mode": "fixed", "amount": 2000.5})),
|
||||||
|
]),
|
||||||
|
json!({"discount": 500.25}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"item code via preset",
|
||||||
|
live.issue(
|
||||||
|
true,
|
||||||
|
json!([
|
||||||
|
hsn(json!({"description": "Preset service", "mode": "fixed", "amount": 1000.0})),
|
||||||
|
hsn(json!({"description": "Code-less", "mode": "fixed", "amount": 250.0})),
|
||||||
|
]),
|
||||||
|
json!({"notes": "Thanks\nsecond line"}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (label, inv) in &cases {
|
||||||
|
live.archive(inv);
|
||||||
|
let r = live.push(inv, None).await;
|
||||||
|
println!("{label}: {} -> {} (warnings {:?})", inv.number, r.remote_name, r.warnings);
|
||||||
|
assert_pushed(&r);
|
||||||
|
assert!(r.created && r.attached, "{label}: {r:?}");
|
||||||
|
assert_eq!(r.remote_name, inv.number, "mirror naming keeps the Voiced number");
|
||||||
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
|
assert_eq!(doc["docstatus"], 0);
|
||||||
|
assert_matches(&live, inv, &doc).await;
|
||||||
|
let files = live.files_of("Sales Invoice", &r.remote_name).await;
|
||||||
|
assert_eq!(files.len(), 1, "{files:?}");
|
||||||
|
assert_eq!(files[0]["is_private"], 1, "the PDF attachment is private");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer and address shape, as ERPNext stored them.
|
||||||
|
let inter = &cases[1].1;
|
||||||
|
let doc = live.doc("Sales Invoice", &inter.number).await;
|
||||||
|
let customer = live.doc("Customer", doc["customer"].as_str().unwrap()).await;
|
||||||
|
assert_eq!(customer["customer_name"], "Voiced Live Inter Co");
|
||||||
|
assert_eq!(customer["customer_type"], "Company");
|
||||||
|
let address = live.doc("Address", doc["customer_address"].as_str().unwrap()).await;
|
||||||
|
println!("address: {}", json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]}));
|
||||||
|
assert_eq!(address["state"], "Karnataka");
|
||||||
|
assert_eq!(address["country"], "India");
|
||||||
|
assert_eq!(address["address_type"], "Billing");
|
||||||
|
assert_eq!(address["links"][0]["link_name"], customer["name"]);
|
||||||
|
if live.ic {
|
||||||
|
assert_eq!(customer["gstin"], KA_GSTIN);
|
||||||
|
assert_eq!(customer["gst_category"], "Registered Regular");
|
||||||
|
assert_eq!(address["gstin"], KA_GSTIN);
|
||||||
|
assert_eq!(doc["place_of_supply"], "29-Karnataka");
|
||||||
|
}
|
||||||
|
// A fresh local database (lost link) reuses the customer's address instead of creating another.
|
||||||
|
let again = Live::new(|_| {}).await;
|
||||||
|
let inv = again.issue(false, json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
|
||||||
|
assert_pushed(&again.push(&inv, None).await);
|
||||||
|
let d2 = again.doc("Sales Invoice", &inv.number).await;
|
||||||
|
assert_eq!(d2["customer_address"], doc["customer_address"], "address reused");
|
||||||
|
assert_eq!(d2["customer"], doc["customer"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
|
async fn live_half_paise_rounding() {
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let banker = env_or("ERPNEXT_ROUNDING", "banker") == "banker";
|
||||||
|
// (taxable amount, discount): 9% of 10.50 is 0.945, 18% of 0.25 is 0.045 and so on. Voiced rounds half up.
|
||||||
|
// Half-even and half-up agree when the digit before the tie is odd (3.50 -> 0.315 -> 0.32), so only the
|
||||||
|
// first two are certain to differ under Banker's Rounding.
|
||||||
|
let ties = [(10.5, 0.0, true), (7310.5, 0.0, true), (3.5, 0.0, false), (0.25, 0.0, false), (99.99, 0.15, false)];
|
||||||
|
for (amount, discount, tie) in ties {
|
||||||
|
let inv = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": format!("Rounding {amount}"), "mode": "fixed", "amount": amount, "hsnSac": "998314"}]),
|
||||||
|
json!({"discount": discount}),
|
||||||
|
);
|
||||||
|
let r = live.push(&inv, Some(true)).await;
|
||||||
|
println!("{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}", r.ok, r.error);
|
||||||
|
if tie && banker {
|
||||||
|
// ERPNext's Banker's Rounding disagrees by a paise: the draft is reported, kept, and never submitted.
|
||||||
|
assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}");
|
||||||
|
let msg = r.error.as_deref().unwrap();
|
||||||
|
assert!(msg.contains("Commercial Rounding") && msg.contains("does not submit"), "{msg}");
|
||||||
|
assert_eq!(r.remote_name, inv.number);
|
||||||
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
|
assert_eq!(doc["docstatus"], 0, "a mismatching document is never submitted");
|
||||||
|
assert!(live.files_of("Sales Invoice", &r.remote_name).await.is_empty());
|
||||||
|
// Pushing again finds the same draft and reports the same conflict instead of creating another.
|
||||||
|
let again = live.push(&inv, Some(true)).await;
|
||||||
|
assert!(!again.ok && again.status == "conflict", "{again:?}");
|
||||||
|
} else {
|
||||||
|
assert_pushed(&r);
|
||||||
|
assert_eq!(r.remote_docstatus, 1);
|
||||||
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
|
assert_matches(&live, &inv, &doc).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
|
async fn live_repush_is_idempotent() {
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let inv = live.issue(true, json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]), json!({}));
|
||||||
|
live.archive(&inv);
|
||||||
|
let first = live.push(&inv, None).await;
|
||||||
|
assert_pushed(&first);
|
||||||
|
assert!(first.created);
|
||||||
|
// Second push with the sync row present: nothing to do.
|
||||||
|
let again = live.push(&inv, None).await;
|
||||||
|
assert_pushed(&again);
|
||||||
|
assert!(again.no_op && !again.created, "{again:?}");
|
||||||
|
// Lose the local sync state: the POST gets a 409 and the document is adopted, not duplicated.
|
||||||
|
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
|
||||||
|
let adopted = live.push(&inv, None).await;
|
||||||
|
assert_pushed(&adopted);
|
||||||
|
assert!(adopted.attached, "{adopted:?}");
|
||||||
|
assert!(!adopted.created, "{adopted:?}");
|
||||||
|
assert_eq!(adopted.remote_name, first.remote_name);
|
||||||
|
let all = live
|
||||||
|
.http
|
||||||
|
.list_resource("Sales Invoice", &["name"], json!([["name", "=", inv.number.clone()]]), "name asc")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(all.len(), 1);
|
||||||
|
// Losing the sync row also loses the attachment hash; the adopted document must not get a second copy.
|
||||||
|
let files = live.files_of("Sales Invoice", &first.remote_name).await;
|
||||||
|
assert_eq!(files.len(), 1, "{files:?}");
|
||||||
|
assert_eq!(files[0]["is_private"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
|
async fn live_submit_paths() {
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
// Submit while creating.
|
||||||
|
let a = live.issue(true, json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
|
||||||
|
let r = live.push(&a, Some(true)).await;
|
||||||
|
assert_pushed(&r);
|
||||||
|
assert_eq!(r.remote_docstatus, 1);
|
||||||
|
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
|
||||||
|
// Submit an existing draft in a later push (mirror: v2 method route).
|
||||||
|
let b = live.issue(true, json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]), json!({}));
|
||||||
|
assert_pushed(&live.push(&b, Some(false)).await);
|
||||||
|
let r = live.push(&b, Some(true)).await;
|
||||||
|
assert_pushed(&r);
|
||||||
|
assert_eq!(r.remote_docstatus, 1);
|
||||||
|
assert_eq!(live.doc("Sales Invoice", &b.number).await["docstatus"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
|
async fn live_series_naming_mode() {
|
||||||
|
let live = Live::new(|c| c.naming_mode = NamingMode::Series).await;
|
||||||
|
let o = live.http.load_options(&live.cfg().company).await.unwrap();
|
||||||
|
let series = o.naming_series.first().expect("a naming series").name.clone();
|
||||||
|
println!("series options: {:?}", o.naming_series);
|
||||||
|
live.save_cfg(|c| c.naming_series = series.clone());
|
||||||
|
let inv = live.issue(true, json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]), json!({"notes": "note"}));
|
||||||
|
live.archive(&inv);
|
||||||
|
let r = live.push(&inv, Some(false)).await;
|
||||||
|
assert_pushed(&r);
|
||||||
|
assert!(r.created);
|
||||||
|
assert_ne!(r.remote_name, inv.number, "series mode lets ERPNext name it");
|
||||||
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
|
assert!(doc["remarks"].as_str().unwrap().starts_with(&format!("Voiced invoice {}", inv.number)), "{}", doc["remarks"]);
|
||||||
|
assert_matches(&live, &inv, &doc).await;
|
||||||
|
// Lose the sync row: the remarks lookup finds the document instead of creating another.
|
||||||
|
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
|
||||||
|
let again = live.push(&inv, Some(false)).await;
|
||||||
|
assert_pushed(&again);
|
||||||
|
assert!(!again.created, "{again:?}");
|
||||||
|
assert_eq!(again.remote_name, r.remote_name);
|
||||||
|
assert_eq!(live.invoices_named(&inv.number).await.len(), 1);
|
||||||
|
// Submit through the v1 run_method route.
|
||||||
|
let s = live.push(&inv, Some(true)).await;
|
||||||
|
assert_pushed(&s);
|
||||||
|
assert_eq!(s.remote_docstatus, 1);
|
||||||
|
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
|
async fn live_uom_and_quantities() {
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
live.preset("Nos preset", "VOICED-SERVICE"); // stock UOM Nos
|
||||||
|
live.preset("Hourly preset", "VOICED-HOURLY"); // stock UOM Hour
|
||||||
|
let row = |desc: &str, unit: &str, qty: f64| json!([{"description": desc, "mode": "rate", "rate": 100.0, "unit": unit, "quantity": qty, "hsnSac": "998314"}]);
|
||||||
|
let mut outcomes = Vec::new();
|
||||||
|
for (label, desc, unit, qty) in [
|
||||||
|
("Hour row on a Nos item, whole qty", "Nos preset", "hour", 2.0),
|
||||||
|
("Hour row on a Nos item, fractional qty", "Nos preset", "hour", 1.5),
|
||||||
|
("Hour row on an Hour item, fractional qty", "Hourly preset", "hour", 1.5),
|
||||||
|
("Nos row on an Hour item, whole qty", "Hourly preset", "unit", 3.0),
|
||||||
|
("code-less hours, fractional qty", "Code-less hours", "hour", 1.5),
|
||||||
|
("code-less minutes, fractional qty", "Code-less minutes", "minute", 90.5),
|
||||||
|
("code-less Nos, fractional qty", "Code-less nos", "unit", 1.5),
|
||||||
|
("code-less Nos, whole qty", "Code-less nos whole", "unit", 4.0),
|
||||||
|
] {
|
||||||
|
let inv = live.issue(true, row(desc, unit, qty), json!({}));
|
||||||
|
let r = live.push(&inv, None).await;
|
||||||
|
println!("{label}: ok={} {}", r.ok, r.error.clone().unwrap_or_default());
|
||||||
|
outcomes.push((label, r.ok));
|
||||||
|
}
|
||||||
|
println!("{outcomes:?}");
|
||||||
|
let ok = |l: &str| outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
|
||||||
|
assert!(ok("Hour row on an Hour item, fractional qty"));
|
||||||
|
assert!(ok("code-less hours, fractional qty") && ok("code-less minutes, fractional qty"));
|
||||||
|
assert!(ok("code-less Nos, whole qty"));
|
||||||
|
assert!(!ok("code-less Nos, fractional qty"), "ERPNext refuses a fraction of a whole-number UOM");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- payments ----
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
|
async fn live_payment_entry() {
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let inv = live.issue(true, json!([{"description": "Billable", "mode": "fixed", "amount": 10000.0, "hsnSac": "998314"}]), json!({}));
|
||||||
|
assert_pushed(&live.push(&inv, Some(true)).await);
|
||||||
|
let total = crate::gst::rupees_to_paise(inv.total);
|
||||||
|
// 10% TDS on the taxable value: 1000.00; the cash received is the rest.
|
||||||
|
let tds = 100_000;
|
||||||
|
let cash = total - tds;
|
||||||
|
let pid = live.payment(&inv, cash, tds);
|
||||||
|
let r = push_payment(&live.db, &live.http, pid).await;
|
||||||
|
println!("payment: {r:?}");
|
||||||
|
assert!(r.ok, "{:?}", r.error);
|
||||||
|
let entry = r.entry_name.clone().unwrap();
|
||||||
|
let pe = live.doc("Payment Entry", &entry).await;
|
||||||
|
let keys = ["payment_type", "party_type", "paid_from", "paid_to", "paid_amount", "received_amount", "base_paid_amount", "base_received_amount", "total_allocated_amount", "unallocated_amount", "difference_amount", "reference_no", "reference_date", "posting_date", "bank_account", "mode_of_payment", "status"];
|
||||||
|
println!("PE {}", json!(keys.iter().map(|k| (k.to_string(), pe[*k].clone())).collect::<serde_json::Map<_, _>>()));
|
||||||
|
println!("PE references {}", pe["references"].as_array().unwrap().iter().map(|r| json!({"ref": r["reference_name"], "allocated": r["allocated_amount"], "total": r["total_amount"], "outstanding": r["outstanding_amount"]}).to_string()).collect::<Vec<_>>().join(","));
|
||||||
|
println!("PE deductions {}", pe["deductions"].as_array().unwrap().iter().map(|r| json!({"account": r["account"], "cost_center": r["cost_center"], "amount": r["amount"], "description": r["description"]}).to_string()).collect::<Vec<_>>().join(","));
|
||||||
|
assert_eq!(paise(&pe["paid_amount"]), cash);
|
||||||
|
assert_eq!(paise(&pe["received_amount"]), cash);
|
||||||
|
assert_eq!(paise(&pe["total_allocated_amount"]), cash + tds);
|
||||||
|
assert_eq!(paise(&pe["difference_amount"]), 0);
|
||||||
|
let ded = pe["deductions"].as_array().unwrap();
|
||||||
|
assert_eq!(ded.len(), 1);
|
||||||
|
assert_eq!(ded[0]["account"], live.cfg().tds_account.as_str());
|
||||||
|
assert_eq!(paise(&ded[0]["amount"]), tds, "a TDS deduction row is a positive amount");
|
||||||
|
assert_eq!(pe["reference_no"], "UTR-LIVE-1");
|
||||||
|
assert_eq!(pe["posting_date"], "2026-06-20");
|
||||||
|
assert_eq!(pe["paid_to"], live.cfg().payment_bank_account.as_str(), "money lands in the configured bank account");
|
||||||
|
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
|
||||||
|
println!("outstanding after payment: {}", inv_doc["outstanding_amount"]);
|
||||||
|
assert_eq!(pe["docstatus"], 1);
|
||||||
|
assert_eq!(paise(&inv_doc["outstanding_amount"]), 0, "cash plus TDS settles the invoice");
|
||||||
|
// Idempotent locally.
|
||||||
|
let again = push_payment(&live.db, &live.http, pid).await;
|
||||||
|
assert!(again.ok && again.already_synced);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
|
async fn live_payment_without_tds() {
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let inv = live.issue(true, json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
||||||
|
assert_pushed(&live.push(&inv, Some(true)).await);
|
||||||
|
let total = crate::gst::rupees_to_paise(inv.total);
|
||||||
|
let pid = live.payment(&inv, total / 2, 0);
|
||||||
|
let r = push_payment(&live.db, &live.http, pid).await;
|
||||||
|
assert!(r.ok, "{:?}", r.error);
|
||||||
|
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
|
||||||
|
assert_eq!(paise(&inv_doc["outstanding_amount"]), total - total / 2, "partial payment leaves the rest outstanding");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- India Compliance only (ERPNEXT_IC=1) ----
|
||||||
|
|
||||||
|
fn skip_without_ic() -> bool {
|
||||||
|
if env_or("ERPNEXT_IC", "0") != "1" {
|
||||||
|
eprintln!("skipped: the site has no India Compliance (ERPNEXT_IC is not 1)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack with India Compliance"]
|
||||||
|
async fn live_ic_hsn_enforcement() {
|
||||||
|
if skip_without_ic() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let mut outcomes = Vec::new();
|
||||||
|
for (label, hsn) in [("no HSN", ""), ("4-digit HSN", "9983"), ("6-digit HSN", "998314"), ("8-digit HSN", "99831400")] {
|
||||||
|
let inv = live.issue(true, json!([{"description": format!("HSN {label}"), "mode": "fixed", "amount": 1000.0, "hsnSac": hsn}]), json!({}));
|
||||||
|
let r = live.push(&inv, Some(true)).await;
|
||||||
|
println!("{label}: ok={} docstatus={} err={:?}", r.ok, r.remote_docstatus, r.error);
|
||||||
|
outcomes.push((label, r));
|
||||||
|
}
|
||||||
|
let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
|
||||||
|
assert!(get("6-digit HSN").ok && get("6-digit HSN").remote_docstatus == 1);
|
||||||
|
assert!(get("8-digit HSN").ok && get("8-digit HSN").remote_docstatus == 1);
|
||||||
|
for bad in ["no HSN", "4-digit HSN"] {
|
||||||
|
let r = get(bad);
|
||||||
|
assert!(!r.ok, "{bad} must be refused: {r:?}");
|
||||||
|
let msg = r.error.as_deref().unwrap();
|
||||||
|
assert!(msg.to_ascii_uppercase().contains("HSN"), "{bad}: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack with India Compliance"]
|
||||||
|
async fn live_ic_number_longer_than_16_characters() {
|
||||||
|
if skip_without_ic() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let inv = live.issue(true, json!([{"description": "Long number", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
||||||
|
// Voiced never issues such a number; this is a number imported or migrated from elsewhere.
|
||||||
|
let long = format!("{}-EXTRA-LONG-PART", inv.number);
|
||||||
|
assert!(long.len() > 16);
|
||||||
|
live.db.lock().unwrap().execute("UPDATE invoices SET number = ?1 WHERE id = ?2", params![long, inv.id]).unwrap();
|
||||||
|
let r = live.push(&inv, None).await;
|
||||||
|
println!("long number: ok={} err={:?}", r.ok, r.error);
|
||||||
|
assert!(!r.ok);
|
||||||
|
let msg = r.error.as_deref().unwrap();
|
||||||
|
assert!(msg.contains("16"), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack with India Compliance"]
|
||||||
|
async fn live_ic_gst_account_warnings() {
|
||||||
|
if skip_without_ic() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
||||||
|
let codes = |ct: &ConnectionTest| ct.warnings.iter().map(|w| w.code.clone()).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let clean = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
|
||||||
|
assert!(clean.warnings.is_empty(), "{:?}", clean.warnings);
|
||||||
|
|
||||||
|
let mut cfg = live.cfg();
|
||||||
|
cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr);
|
||||||
|
let wrong = test_connection(&live.http, &cfg, &local).await.unwrap();
|
||||||
|
assert_eq!(codes(&wrong), ["gst_account_mismatch"], "{:?}", wrong.warnings);
|
||||||
|
|
||||||
|
let mut cfg = live.cfg();
|
||||||
|
cfg.company = "No Such Co".into();
|
||||||
|
let other = test_connection(&live.http, &cfg, &local).await.unwrap();
|
||||||
|
assert!(codes(&other).contains(&"gst_accounts_not_configured".to_string()), "{:?}", other.warnings);
|
||||||
|
|
||||||
|
// Mirror mode with a number the site would refuse.
|
||||||
|
let local = LocalFacts { vendor_registered: true, next_number: Some("TOOLONG-INVOICE-0001".into()), ..Default::default() };
|
||||||
|
let ct = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
|
||||||
|
assert!(codes(&ct).contains(&"number_invalid".to_string()), "{:?}", ct.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack with India Compliance"]
|
||||||
|
async fn live_ic_reverse_charge_and_gst_fields() {
|
||||||
|
if skip_without_ic() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let inv = live.issue(false, json!([{"description": "RCM service", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({"reverseCharge": true}));
|
||||||
|
let r = live.push(&inv, None).await;
|
||||||
|
// India Compliance refuses is_reverse_charge=1 without negative RCM tax rows ("Booked reverse charge is not
|
||||||
|
// equal to applied tax amount"), whatever GST Settings say, so it is sent as 0 with a warning.
|
||||||
|
assert_pushed(&r);
|
||||||
|
assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings);
|
||||||
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
|
assert_eq!(doc["is_reverse_charge"], 0);
|
||||||
|
assert_matches(&live, &inv, &doc).await;
|
||||||
|
assert_eq!(doc["place_of_supply"], "29-Karnataka");
|
||||||
|
assert_eq!(doc["company_gstin"], VENDOR_GSTIN);
|
||||||
|
assert_eq!(doc["gst_category"], "Registered Regular");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack with India Compliance"]
|
||||||
|
async fn live_ic_state_names_match_voiced() {
|
||||||
|
if skip_without_ic() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let v = live.http.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Address".to_string())]).await.unwrap();
|
||||||
|
let docs = v["docs"].as_array().unwrap();
|
||||||
|
let address = docs.iter().find(|d| d["name"] == "Address").unwrap();
|
||||||
|
let options = address["fields"].as_array().unwrap().iter().find(|f| f["fieldname"] == "gst_state").expect("IC adds gst_state");
|
||||||
|
let ic: Vec<&str> = options["options"].as_str().unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).collect();
|
||||||
|
for (code, name) in crate::gst::STATES {
|
||||||
|
assert!(ic.contains(name), "India Compliance has no state named {name:?} ({code}); it has {ic:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
|
async fn live_payment_edge_cases() {
|
||||||
|
let live = Live::new(|_| {}).await;
|
||||||
|
let inv = live.issue(true, json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
||||||
|
// Not submitted yet: refused locally, nothing sent.
|
||||||
|
assert_pushed(&live.push(&inv, Some(false)).await);
|
||||||
|
let early = live.payment(&inv, 100_00, 0);
|
||||||
|
let r = push_payment(&live.db, &live.http, early).await;
|
||||||
|
assert!(!r.ok && r.error.as_deref().unwrap().contains("not submitted"), "{r:?}");
|
||||||
|
assert_pushed(&live.push(&inv, Some(true)).await);
|
||||||
|
let total = crate::gst::rupees_to_paise(inv.total);
|
||||||
|
// TDS only (no cash): settled by deduction alone.
|
||||||
|
let tds_only = live.payment(&inv, 0, total - 100_00);
|
||||||
|
let r = push_payment(&live.db, &live.http, tds_only).await;
|
||||||
|
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS only"), "{r:?}");
|
||||||
|
// Without a configured TDS account a TDS payment is refused locally.
|
||||||
|
live.save_cfg(|c| c.tds_account = String::new());
|
||||||
|
let inv2 = live.issue(true, json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
||||||
|
assert_pushed(&live.push(&inv2, Some(true)).await);
|
||||||
|
let t = live.payment(&inv2, 100_00, 50_00);
|
||||||
|
let r = push_payment(&live.db, &live.http, t).await;
|
||||||
|
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS account"), "{r:?}");
|
||||||
|
}
|
||||||
@@ -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,117 @@
|
|||||||
|
//! 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 commands;
|
||||||
mod db;
|
mod db;
|
||||||
mod gst;
|
mod gst;
|
||||||
|
mod integrations;
|
||||||
mod logo;
|
mod logo;
|
||||||
mod models;
|
mod models;
|
||||||
mod selftest;
|
mod selftest;
|
||||||
@@ -56,6 +57,15 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
// A restore staged by the Data tab is swapped in now, before anything opens the database.
|
||||||
|
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
|
||||||
|
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
||||||
|
commands::backup::ApplyOutcome::NothingPending => {}
|
||||||
|
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
||||||
|
eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
|
||||||
|
}
|
||||||
|
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
|
||||||
|
}
|
||||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -71,6 +81,7 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
|
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
|
||||||
eprintln!("Could not derive the logo images: {e}");
|
eprintln!("Could not derive the logo images: {e}");
|
||||||
}
|
}
|
||||||
|
commands::backup::spawn_auto_backup(dirs);
|
||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
db: Mutex::new(conn),
|
db: Mutex::new(conn),
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -80,6 +91,9 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
// After "Restart" in the Data tab the new process waits for the old one (and its single-instance lock) to go.
|
||||||
|
commands::backup::wait_if_relaunched();
|
||||||
|
|
||||||
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
||||||
// combinations. Respect an explicit user setting, otherwise turn it off.
|
// combinations. Respect an explicit user setting, otherwise turn it off.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -121,6 +135,9 @@ pub fn run() {
|
|||||||
commands::clients::list_clients,
|
commands::clients::list_clients,
|
||||||
commands::clients::save_client,
|
commands::clients::save_client,
|
||||||
commands::clients::delete_client,
|
commands::clients::delete_client,
|
||||||
|
commands::presets::list_item_presets,
|
||||||
|
commands::presets::save_item_preset,
|
||||||
|
commands::presets::delete_item_preset,
|
||||||
commands::invoice::peek_next_invoice_number,
|
commands::invoice::peek_next_invoice_number,
|
||||||
commands::invoice::issue_invoice,
|
commands::invoice::issue_invoice,
|
||||||
commands::invoice::cancel_invoice,
|
commands::invoice::cancel_invoice,
|
||||||
@@ -130,6 +147,16 @@ pub fn run() {
|
|||||||
commands::invoice::list_drafts,
|
commands::invoice::list_drafts,
|
||||||
commands::invoice::get_draft,
|
commands::invoice::get_draft,
|
||||||
commands::invoice::delete_draft,
|
commands::invoice::delete_draft,
|
||||||
|
commands::payments::record_payment,
|
||||||
|
commands::payments::list_payments,
|
||||||
|
commands::payments::delete_payment,
|
||||||
|
commands::backup::create_backup,
|
||||||
|
commands::backup::restore_backup,
|
||||||
|
commands::backup::cancel_pending_restore,
|
||||||
|
commands::backup::get_backup_status,
|
||||||
|
commands::backup::set_auto_backup,
|
||||||
|
commands::backup::restart_app,
|
||||||
|
commands::invoice::list_invoice_ledger,
|
||||||
commands::assets::import_asset,
|
commands::assets::import_asset,
|
||||||
commands::assets::save_asset_bytes,
|
commands::assets::save_asset_bytes,
|
||||||
commands::assets::read_asset_data_uri,
|
commands::assets::read_asset_data_uri,
|
||||||
@@ -150,6 +177,16 @@ pub fn run() {
|
|||||||
commands::files::get_last_export_dir,
|
commands::files::get_last_export_dir,
|
||||||
commands::files::reveal_in_folder,
|
commands::files::reveal_in_folder,
|
||||||
commands::files::open_file,
|
commands::files::open_file,
|
||||||
|
commands::erpnext::erpnext_get_config,
|
||||||
|
commands::erpnext::erpnext_save_config,
|
||||||
|
commands::erpnext::erpnext_test_connection,
|
||||||
|
commands::erpnext::erpnext_load_options,
|
||||||
|
commands::erpnext::erpnext_push_invoice,
|
||||||
|
commands::erpnext::erpnext_push_invoices,
|
||||||
|
commands::erpnext::erpnext_push_payment,
|
||||||
|
commands::erpnext::erpnext_sync_status,
|
||||||
|
commands::erpnext::erpnext_sync_statuses,
|
||||||
|
commands::erpnext::erpnext_open_url,
|
||||||
selftest::selftest_config,
|
selftest::selftest_config,
|
||||||
selftest::selftest_report,
|
selftest::selftest_report,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ fn clean_alpha(img: &mut RgbaImage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Keep 64 levels per channel (steps of about 4/255, invisible in print). Anti-aliasing and encoder
|
/// Keep 64 levels per channel (steps of about 4/255, invisible in print). Anti-aliasing and encoder
|
||||||
/// noise of +-1 otherwise makes flat colours incompressible: the Test Vendor logo drops from 149 KB to 75 KB.
|
/// noise of +-1 otherwise makes flat colours incompressible: a sample wordmark logo drops from 149 KB to 75 KB.
|
||||||
fn quantize_colour(img: &mut RgbaImage) {
|
fn quantize_colour(img: &mut RgbaImage) {
|
||||||
for p in img.pixels_mut() {
|
for p in img.pixels_mut() {
|
||||||
for c in 0..3 {
|
for c in 0..3 {
|
||||||
@@ -535,13 +535,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sample_logo_is_trimmed_and_small() {
|
fn sample_logo_is_trimmed_and_small() {
|
||||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/brand/sample-wordmark-logo.png");
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/pdf/testing/fixtures/sample-wordmark-logo.png");
|
||||||
let bytes = std::fs::read(path).unwrap();
|
let bytes = std::fs::read(path).unwrap();
|
||||||
let out = process_logo(&bytes).unwrap();
|
let out = process_logo(&bytes).unwrap();
|
||||||
let m = &out.metrics;
|
let m = &out.metrics;
|
||||||
assert_eq!((m.source_width, m.source_height), (2172, 724));
|
assert_eq!((m.source_width, m.source_height), (2172, 724));
|
||||||
assert!((4.5..=4.7).contains(&m.aspect), "aspect {}", m.aspect);
|
assert!((5.3..=5.6).contains(&m.aspect), "aspect {}", m.aspect);
|
||||||
assert!((0.40..=0.46).contains(&m.ink_density), "density {}", m.ink_density);
|
assert!((0.50..=0.60).contains(&m.ink_density), "density {}", m.ink_density);
|
||||||
assert_eq!(m.kind, LogoKind::Wordmark);
|
assert_eq!(m.kind, LogoKind::Wordmark);
|
||||||
assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len());
|
assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len());
|
||||||
assert!(out.knockout_png.is_some());
|
assert!(out.knockout_png.is_some());
|
||||||
|
|||||||
@@ -95,6 +95,50 @@ pub struct Client {
|
|||||||
pub po_number: String,
|
pub po_number: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub address_line1: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub address_line2: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub city: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pincode: String,
|
||||||
|
/// registered_regular | unregistered | composition | sez | overseas. Informational only.
|
||||||
|
#[serde(default = "default_gst_category")]
|
||||||
|
pub gst_category: String,
|
||||||
|
/// Notes / payment terms text copied onto a new invoice when this client is picked.
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_notes: String,
|
||||||
|
/// None = use the settings default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub payment_terms_days: Option<i64>,
|
||||||
|
/// Number of invoices that reference this client; read-only, filled by list_clients.
|
||||||
|
#[serde(default)]
|
||||||
|
pub invoice_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_gst_category() -> String {
|
||||||
|
"unregistered".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ItemPreset {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hsn_sac: String,
|
||||||
|
/// "fixed" = rate_paise is the line amount; "rate" = rate_paise is the rate per unit.
|
||||||
|
#[serde(default = "default_mode")]
|
||||||
|
pub mode: String,
|
||||||
|
#[serde(default = "default_unit")]
|
||||||
|
pub unit: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rate_paise: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sort_order: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -221,6 +265,34 @@ pub struct Invoice {
|
|||||||
pub archived_fingerprint: Option<String>,
|
pub archived_fingerprint: Option<String>,
|
||||||
pub archived_at: Option<String>,
|
pub archived_at: Option<String>,
|
||||||
pub items: Vec<InvoiceItem>,
|
pub items: Vec<InvoiceItem>,
|
||||||
|
/// Derived from the payments table (never stored on the invoice): see commands::payments.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub payment: PaymentSummary,
|
||||||
|
/// `total` in integer paise.
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_paise: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What has been received against an invoice. All amounts are integer paise; `payment_status` is
|
||||||
|
/// unpaid | partially_paid | paid | overdue, or `none` for an invoice that takes no payments.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PaymentSummary {
|
||||||
|
pub paid_paise: i64,
|
||||||
|
pub tds_paise: i64,
|
||||||
|
pub balance_paise: i64,
|
||||||
|
pub payment_status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PaymentSummary {
|
||||||
|
fn default() -> Self {
|
||||||
|
PaymentSummary {
|
||||||
|
paid_paise: 0,
|
||||||
|
tds_paise: 0,
|
||||||
|
balance_paise: 0,
|
||||||
|
payment_status: "none".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -234,6 +306,10 @@ pub struct InvoiceSummary {
|
|||||||
pub total: f64,
|
pub total: f64,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub po_number: String,
|
||||||
|
pub total_paise: i64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub payment: PaymentSummary,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Voiced",
|
"productName": "Voiced",
|
||||||
"version": "0.1.0",
|
"version": "1.0.2",
|
||||||
"identifier": "com.bongbetic.voiced",
|
"identifier": "com.bongbetic.voiced",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -6,14 +6,30 @@ import { AppShell, type AppView } from "./components/AppShell";
|
|||||||
import Onboarding from "./views/Onboarding";
|
import Onboarding from "./views/Onboarding";
|
||||||
import NewInvoice from "./views/NewInvoice";
|
import NewInvoice from "./views/NewInvoice";
|
||||||
import InvoiceHistory from "./views/InvoiceHistory";
|
import InvoiceHistory from "./views/InvoiceHistory";
|
||||||
import SeriesSettings from "./views/SeriesSettings";
|
import InvoiceDetail from "./views/InvoiceDetail";
|
||||||
|
import Clients from "./views/Clients";
|
||||||
|
import { ToastProvider, useToast } from "./components/ToastProvider";
|
||||||
import AppSettings from "./views/AppSettings";
|
import AppSettings from "./views/AppSettings";
|
||||||
|
import { requestSettingsTab } from "./lib/settingsTab";
|
||||||
|
import { applyDocumentTheme, cachedTheme, normalizeTheme } from "./lib/theme";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<AppInner />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppInner() {
|
||||||
|
const toast = useToast();
|
||||||
const [settings, setSettings] = useState<Settings | null>(null);
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [view, setView] = useState<AppView>("new");
|
const [view, setView] = useState<AppView>("new");
|
||||||
|
const [detailId, setDetailId] = useState<number | null>(null);
|
||||||
|
// A draft made by "Duplicate" that the New invoice form should open once it is shown.
|
||||||
|
const [openDraftId, setOpenDraftId] = useState<number | null>(null);
|
||||||
|
|
||||||
const refreshSettings = useCallback(async () => {
|
const refreshSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -31,9 +47,15 @@ export default function App() {
|
|||||||
void refreshSettings();
|
void refreshSettings();
|
||||||
}, [refreshSettings]);
|
}, [refreshSettings]);
|
||||||
|
|
||||||
|
// Before settings load (and on the error screen) use the cached theme so the window never flashes white.
|
||||||
|
const theme = settings ? normalizeTheme(settings.theme) : cachedTheme();
|
||||||
|
useEffect(() => {
|
||||||
|
applyDocumentTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Theme theme="g10">
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<div className="voiced-empty">
|
<div className="voiced-empty">
|
||||||
<Loading description="Loading Voiced" withOverlay={false} small />
|
<Loading description="Loading Voiced" withOverlay={false} small />
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +65,7 @@ export default function App() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Theme theme="g10">
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<div className="voiced-page">
|
<div className="voiced-page">
|
||||||
<InlineNotification
|
<InlineNotification
|
||||||
kind="error"
|
kind="error"
|
||||||
@@ -59,11 +81,9 @@ export default function App() {
|
|||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
const theme = settings.theme === "g100" ? "g100" : "g10";
|
|
||||||
|
|
||||||
if (!settings.onboarded) {
|
if (!settings.onboarded) {
|
||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<Onboarding
|
<Onboarding
|
||||||
initial={settings}
|
initial={settings}
|
||||||
onDone={(next) => {
|
onDone={(next) => {
|
||||||
@@ -75,31 +95,61 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openIntegrations = () => {
|
||||||
|
requestSettingsTab("integrations");
|
||||||
|
setView("settings");
|
||||||
|
};
|
||||||
|
|
||||||
const toggleTheme = async () => {
|
const toggleTheme = async () => {
|
||||||
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
|
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
|
||||||
setSettings(next);
|
setSettings(next);
|
||||||
try {
|
try {
|
||||||
await api.saveSettings(next);
|
await api.saveSettings(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
toast.error("Could not save the theme", String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme} className="voiced-theme">
|
||||||
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
<AppShell view={view} onNavigate={setView} theme={theme} onToggleTheme={toggleTheme}>
|
||||||
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
||||||
<NewInvoice
|
<NewInvoice
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onSettingsChange={refreshSettings}
|
onSettingsChange={refreshSettings}
|
||||||
active={view === "new"}
|
active={view === "new"}
|
||||||
onActivate={() => setView("new")}
|
onActivate={() => setView("new")}
|
||||||
|
openDraftId={openDraftId}
|
||||||
|
onDraftOpened={() => setOpenDraftId(null)}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
/>
|
/>
|
||||||
<div hidden={view !== "invoices"}>
|
<div hidden={view !== "invoices"}>
|
||||||
<InvoiceHistory settings={settings} active={view === "invoices"} />
|
<InvoiceHistory
|
||||||
|
settings={settings}
|
||||||
|
active={view === "invoices"}
|
||||||
|
onOpenSettings={openIntegrations}
|
||||||
|
onOpen={(id) => {
|
||||||
|
setDetailId(id);
|
||||||
|
setView("invoice");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div hidden={view !== "series"}>
|
{view === "invoice" && detailId !== null ? (
|
||||||
<SeriesSettings active={view === "series"} />
|
<InvoiceDetail
|
||||||
|
key={detailId}
|
||||||
|
invoiceId={detailId}
|
||||||
|
settings={settings}
|
||||||
|
onBack={() => setView("invoices")}
|
||||||
|
onOpenSettings={openIntegrations}
|
||||||
|
onDuplicated={(draftId) => {
|
||||||
|
setOpenDraftId(draftId);
|
||||||
|
setView("new");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div hidden={view !== "clients"}>
|
||||||
|
<Clients active={view === "clients"} />
|
||||||
</div>
|
</div>
|
||||||
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import {
|
|||||||
import { Asleep, Light } from "@carbon/icons-react";
|
import { Asleep, Light } from "@carbon/icons-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export type AppView = "new" | "invoices" | "series" | "settings";
|
/** "invoice" is the detail page of one invoice; it is reached from "invoices" and highlights that tab. */
|
||||||
|
export type AppView = "new" | "invoices" | "invoice" | "clients" | "settings";
|
||||||
|
|
||||||
const NAV: { id: AppView; label: string }[] = [
|
const NAV: { id: Exclude<AppView, "invoice">; label: string }[] = [
|
||||||
{ id: "new", label: "New invoice" },
|
{ id: "new", label: "New invoice" },
|
||||||
{ id: "invoices", label: "Invoices" },
|
{ id: "invoices", label: "Invoices" },
|
||||||
{ id: "series", label: "Series" },
|
{ id: "clients", label: "Clients" },
|
||||||
{ id: "settings", label: "Settings" },
|
{ id: "settings", label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
|||||||
{NAV.map((item) => (
|
{NAV.map((item) => (
|
||||||
<HeaderMenuItem
|
<HeaderMenuItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
isCurrentPage={view === item.id}
|
isCurrentPage={view === item.id || (view === "invoice" && item.id === "invoices")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onNavigate(item.id);
|
onNavigate(item.id);
|
||||||
@@ -60,7 +61,11 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
|||||||
))}
|
))}
|
||||||
</HeaderNavigation>
|
</HeaderNavigation>
|
||||||
<HeaderGlobalBar>
|
<HeaderGlobalBar>
|
||||||
<HeaderGlobalAction aria-label="Toggle colour theme" onClick={onToggleTheme}>
|
<HeaderGlobalAction
|
||||||
|
aria-label={theme === "g100" ? "Switch to light theme" : "Switch to dark theme"}
|
||||||
|
tooltipAlignment="end"
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
>
|
||||||
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
{theme === "g100" ? <Light size={20} /> : <Asleep size={20} />}
|
||||||
</HeaderGlobalAction>
|
</HeaderGlobalAction>
|
||||||
</HeaderGlobalBar>
|
</HeaderGlobalBar>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState, type RefObject } from "react";
|
||||||
|
import { Column, Grid, Modal, TextInput, Toggle } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { BankAccount } from "../lib/types";
|
||||||
|
import { ifscError } from "../lib/validators";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
export const blankBank = (): BankAccount => ({
|
||||||
|
id: null,
|
||||||
|
label: "",
|
||||||
|
bankName: "",
|
||||||
|
accountName: "",
|
||||||
|
accountNo: "",
|
||||||
|
branch: "",
|
||||||
|
ifsc: "",
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BankAccountModalProps {
|
||||||
|
open: boolean;
|
||||||
|
/** The account to edit; null opens a blank one. */
|
||||||
|
bank: BankAccount | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (bank: BankAccount) => void;
|
||||||
|
/** Focus returns here when the modal closes. */
|
||||||
|
launcherRef?: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BankAccountModal({ open, bank, onClose, onSaved, launcherRef }: BankAccountModalProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [form, setForm] = useState<BankAccount>(blankBank());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setForm(bank ? { ...bank } : blankBank());
|
||||||
|
setBusy(false);
|
||||||
|
setTouched(false);
|
||||||
|
}, [open, bank]);
|
||||||
|
|
||||||
|
const set = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const bankNameProblem = form.bankName.trim() ? undefined : "Bank name is required";
|
||||||
|
const accountNoProblem = form.accountNo.trim() ? undefined : "Account number is required";
|
||||||
|
const ifscProblem = ifscError(form.ifsc);
|
||||||
|
const blocking = bankNameProblem ?? accountNoProblem ?? ifscProblem;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setTouched(true);
|
||||||
|
if (blocking) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const saved = await api.saveBank({
|
||||||
|
...form,
|
||||||
|
bankName: form.bankName.trim(),
|
||||||
|
accountNo: form.accountNo.trim(),
|
||||||
|
ifsc: form.ifsc.trim().toUpperCase(),
|
||||||
|
});
|
||||||
|
toast.success(form.id === null ? "Bank account added" : "Bank account saved", saved.bankName);
|
||||||
|
onSaved(saved);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not save bank account", String(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
modalHeading={bank?.id ? "Edit bank account" : "New bank account"}
|
||||||
|
primaryButtonText={busy ? "Saving…" : "Save account"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={busy}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onRequestSubmit={() => void submit()}
|
||||||
|
launcherButtonRef={launcherRef}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Grid narrow>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-label"
|
||||||
|
labelText="Label (optional)"
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => set("label", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-bankName"
|
||||||
|
labelText="Bank name"
|
||||||
|
value={form.bankName}
|
||||||
|
onChange={(e) => set("bankName", e.target.value)}
|
||||||
|
invalid={touched && Boolean(bankNameProblem)}
|
||||||
|
invalidText={bankNameProblem}
|
||||||
|
data-modal-primary-focus
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-accountName"
|
||||||
|
labelText="Account name"
|
||||||
|
value={form.accountName}
|
||||||
|
onChange={(e) => set("accountName", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-accountNo"
|
||||||
|
labelText="Account number"
|
||||||
|
value={form.accountNo}
|
||||||
|
onChange={(e) => set("accountNo", e.target.value)}
|
||||||
|
invalid={touched && Boolean(accountNoProblem)}
|
||||||
|
invalidText={accountNoProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-branch"
|
||||||
|
labelText="Branch"
|
||||||
|
value={form.branch}
|
||||||
|
onChange={(e) => set("branch", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="bm-ifsc"
|
||||||
|
labelText="IFSC"
|
||||||
|
value={form.ifsc}
|
||||||
|
onChange={(e) => set("ifsc", e.target.value.toUpperCase())}
|
||||||
|
invalid={Boolean(ifscProblem)}
|
||||||
|
invalidText={ifscProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<Toggle
|
||||||
|
id="bm-default"
|
||||||
|
labelText="Use as default account"
|
||||||
|
toggled={form.isDefault}
|
||||||
|
onToggle={(value) => set("isDefault", value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useEffect, useState, type RefObject } from "react";
|
||||||
|
import { Column, Grid, InlineNotification, Modal, Select, SelectItem, TextArea, TextInput } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import {
|
||||||
|
GST_CATEGORY_LABELS,
|
||||||
|
INDIAN_STATES,
|
||||||
|
blankClient,
|
||||||
|
type Client,
|
||||||
|
type GstCategory,
|
||||||
|
} from "../lib/types";
|
||||||
|
import { composeAddress, pincodeError, splitLegacyAddress } from "../lib/clientAddress";
|
||||||
|
import { clientGstinStateError } from "../lib/validators";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
interface ClientModalProps {
|
||||||
|
open: boolean;
|
||||||
|
/** The client to edit; null opens a blank one. */
|
||||||
|
client: Client | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (client: Client) => void;
|
||||||
|
/** Focus returns here when the modal closes. */
|
||||||
|
launcherRef?: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills the structured address from the legacy free-text one when a client predates the new fields. */
|
||||||
|
export function prepareForEdit(client: Client): { form: Client; splitFromLegacy: boolean } {
|
||||||
|
const structured = client.addressLine1 || client.addressLine2 || client.city || client.pincode;
|
||||||
|
if (structured || !client.address.trim()) return { form: client, splitFromLegacy: false };
|
||||||
|
return { form: { ...client, ...splitLegacyAddress(client.address) }, splitFromLegacy: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientModal({ open, client, onClose, onSaved, launcherRef }: ClientModalProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [form, setForm] = useState<Client>(blankClient());
|
||||||
|
const [split, setSplit] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const prepared = prepareForEdit(client ?? blankClient());
|
||||||
|
setForm(prepared.form);
|
||||||
|
setSplit(prepared.splitFromLegacy);
|
||||||
|
setBusy(false);
|
||||||
|
setTouched(false);
|
||||||
|
}, [open, client]);
|
||||||
|
|
||||||
|
const set = <K extends keyof Client>(key: K, value: Client[K]) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const nameProblem = form.name.trim() ? undefined : "Client name is required";
|
||||||
|
const gstinProblem = clientGstinStateError(form.gstin, form.stateCode);
|
||||||
|
const pinProblem = pincodeError(form.pincode);
|
||||||
|
const termsProblem =
|
||||||
|
form.paymentTermsDays !== null && (form.paymentTermsDays < 0 || form.paymentTermsDays > 365)
|
||||||
|
? "Between 0 and 365 days"
|
||||||
|
: undefined;
|
||||||
|
const blocking = nameProblem ?? gstinProblem ?? pinProblem ?? termsProblem;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setTouched(true);
|
||||||
|
if (blocking) return;
|
||||||
|
setBusy(true);
|
||||||
|
const structured = form.addressLine1 || form.addressLine2 || form.city || form.pincode;
|
||||||
|
const toSave: Client = {
|
||||||
|
...form,
|
||||||
|
name: form.name.trim(),
|
||||||
|
gstin: form.gstin.trim().toUpperCase(),
|
||||||
|
address: structured
|
||||||
|
? composeAddress({
|
||||||
|
line1: form.addressLine1,
|
||||||
|
line2: form.addressLine2,
|
||||||
|
city: form.city,
|
||||||
|
stateCode: form.stateCode,
|
||||||
|
pincode: form.pincode,
|
||||||
|
})
|
||||||
|
: form.address,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const saved = await api.saveClient(toSave);
|
||||||
|
toast.success(toSave.id === null ? "Client added" : "Client updated", saved.name);
|
||||||
|
onSaved(saved);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not save client", String(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
modalHeading={client?.id ? "Edit client" : "New client"}
|
||||||
|
primaryButtonText={busy ? "Saving…" : "Save client"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={busy}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onRequestSubmit={() => void submit()}
|
||||||
|
launcherButtonRef={launcherRef}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Grid narrow>
|
||||||
|
{split ? (
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<InlineNotification
|
||||||
|
kind="info"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="Address split automatically"
|
||||||
|
subtitle="This client has a free-text address. Check the lines below before saving."
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
) : null}
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-name"
|
||||||
|
labelText="Client name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
invalid={touched && Boolean(nameProblem)}
|
||||||
|
invalidText={nameProblem}
|
||||||
|
data-modal-primary-focus
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<Select
|
||||||
|
id="cm-category"
|
||||||
|
labelText="GST category"
|
||||||
|
value={form.gstCategory}
|
||||||
|
onChange={(e) => set("gstCategory", e.target.value as GstCategory)}
|
||||||
|
>
|
||||||
|
{Object.entries(GST_CATEGORY_LABELS).map(([value, text]) => (
|
||||||
|
<SelectItem key={value} value={value} text={text} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-gstin"
|
||||||
|
labelText="GSTIN (optional)"
|
||||||
|
value={form.gstin}
|
||||||
|
onChange={(e) => set("gstin", e.target.value.toUpperCase())}
|
||||||
|
invalid={Boolean(form.gstin.trim()) && Boolean(gstinProblem)}
|
||||||
|
invalidText={gstinProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-line1"
|
||||||
|
labelText="Address line 1"
|
||||||
|
value={form.addressLine1}
|
||||||
|
onChange={(e) => set("addressLine1", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-line2"
|
||||||
|
labelText="Address line 2"
|
||||||
|
value={form.addressLine2}
|
||||||
|
onChange={(e) => set("addressLine2", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={5}>
|
||||||
|
<TextInput id="cm-city" labelText="City" value={form.city} onChange={(e) => set("city", e.target.value)} />
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={7}>
|
||||||
|
<Select
|
||||||
|
id="cm-state"
|
||||||
|
labelText="State (also the default place of supply)"
|
||||||
|
value={form.stateCode}
|
||||||
|
onChange={(e) => set("stateCode", e.target.value)}
|
||||||
|
>
|
||||||
|
<SelectItem value="" text="Not set" />
|
||||||
|
{INDIAN_STATES.map((s) => (
|
||||||
|
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={4}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-pin"
|
||||||
|
labelText="Pincode"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
value={form.pincode}
|
||||||
|
onChange={(e) => set("pincode", e.target.value)}
|
||||||
|
invalid={Boolean(pinProblem)}
|
||||||
|
invalidText={pinProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-po"
|
||||||
|
labelText="Default PO number (optional)"
|
||||||
|
value={form.poNumber}
|
||||||
|
onChange={(e) => set("poNumber", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={4} lg={8}>
|
||||||
|
<TextInput
|
||||||
|
id="cm-terms"
|
||||||
|
labelText="Payment terms (days, blank = default)"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.paymentTermsDays === null ? "" : String(form.paymentTermsDays)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.trim();
|
||||||
|
set("paymentTermsDays", raw === "" ? null : Number.isFinite(Number(raw)) ? Math.trunc(Number(raw)) : null);
|
||||||
|
}}
|
||||||
|
invalid={Boolean(termsProblem)}
|
||||||
|
invalidText={termsProblem}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column sm={4} md={8} lg={16}>
|
||||||
|
<TextArea
|
||||||
|
id="cm-notes"
|
||||||
|
labelText="Default notes / payment terms text"
|
||||||
|
helperText="Copied into the invoice notes when this client is picked and the notes are empty."
|
||||||
|
rows={3}
|
||||||
|
value={form.defaultNotes}
|
||||||
|
onChange={(e) => set("defaultNotes", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
|
||||||
|
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { Settings } from "../lib/types";
|
||||||
|
import {
|
||||||
|
backupFileName,
|
||||||
|
ensureZipExtension,
|
||||||
|
formatBytes,
|
||||||
|
formatWhen,
|
||||||
|
type BackupStatus,
|
||||||
|
type PendingRestore,
|
||||||
|
} from "../lib/backup";
|
||||||
|
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
const ZIP_FILTER = [{ name: "Voiced backup", extensions: ["zip"] }];
|
||||||
|
|
||||||
|
type RestoreStep = "confirm" | "staging" | "ready";
|
||||||
|
|
||||||
|
const joinPath = (dir: string, name: string) =>
|
||||||
|
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
||||||
|
|
||||||
|
interface DataBackupPanelProps {
|
||||||
|
/** The saved settings; "Run setup again" writes them back with the wizard flag cleared. */
|
||||||
|
settings: Settings;
|
||||||
|
/** Called after the flag is cleared, so the app reloads settings and shows the wizard. */
|
||||||
|
onRerunSetup: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */
|
||||||
|
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [status, setStatus] = useState<BackupStatus | null>(null);
|
||||||
|
const [backingUp, setBackingUp] = useState(false);
|
||||||
|
const [restorePath, setRestorePath] = useState<string | null>(null);
|
||||||
|
const [step, setStep] = useState<RestoreStep>("confirm");
|
||||||
|
const [staged, setStaged] = useState<PendingRestore | null>(null);
|
||||||
|
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
useReturnFocus(restorePath !== null);
|
||||||
|
|
||||||
|
const rerunSetup = async () => {
|
||||||
|
const ok = await confirm(
|
||||||
|
"Open the setup wizard again? Your invoices, clients and backups are kept, and the wizard starts with your current details.",
|
||||||
|
{ title: "Run setup again", kind: "warning" },
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api.saveSettings({ ...settings, onboarded: false });
|
||||||
|
onRerunSetup();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not start setup", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setStatus(await api.getBackupStatus());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not load backup status", String(e));
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const backUpNow = async () => {
|
||||||
|
setBackingUp(true);
|
||||||
|
try {
|
||||||
|
let dir = "";
|
||||||
|
try {
|
||||||
|
dir = await api.getLastExportDir();
|
||||||
|
} catch {
|
||||||
|
// No remembered folder: the dialog opens wherever the system prefers.
|
||||||
|
}
|
||||||
|
const chosen = await save({ defaultPath: joinPath(dir, backupFileName()), filters: ZIP_FILTER });
|
||||||
|
if (!chosen) return;
|
||||||
|
const summary = await api.createBackup(ensureZipExtension(chosen));
|
||||||
|
toast.success("Backup saved", `${summary.fileCount} files, ${formatBytes(summary.totalBytes)}: ${summary.path}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Backup failed", String(e));
|
||||||
|
} finally {
|
||||||
|
setBackingUp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseRestoreFile = async () => {
|
||||||
|
try {
|
||||||
|
const picked = await open({ multiple: false, directory: false, filters: ZIP_FILTER });
|
||||||
|
if (typeof picked !== "string") return;
|
||||||
|
setRestoreError(null);
|
||||||
|
setStaged(null);
|
||||||
|
setStep("confirm");
|
||||||
|
setRestorePath(picked);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not open the file picker", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRestore = () => {
|
||||||
|
if (step === "staging") return;
|
||||||
|
setRestorePath(null);
|
||||||
|
void refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageRestore = async () => {
|
||||||
|
if (!restorePath) return;
|
||||||
|
setStep("staging");
|
||||||
|
setRestoreError(null);
|
||||||
|
try {
|
||||||
|
setStaged(await api.restoreBackup(restorePath));
|
||||||
|
setStep("ready");
|
||||||
|
} catch (e) {
|
||||||
|
setRestoreError(String(e));
|
||||||
|
setStep("confirm");
|
||||||
|
toast.error("This backup cannot be restored", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = async () => {
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
await api.restartApp();
|
||||||
|
} catch (e) {
|
||||||
|
setRestarting(false);
|
||||||
|
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the restore.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPending = async () => {
|
||||||
|
try {
|
||||||
|
await api.cancelPendingRestore();
|
||||||
|
toast.info("Restore cancelled", "Your data was not changed.");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not cancel the restore", String(e));
|
||||||
|
}
|
||||||
|
setRestorePath(null);
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAuto = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const saved = await api.setAutoBackup(enabled);
|
||||||
|
setStatus((prev) => (prev ? { ...prev, autoBackup: saved } : prev));
|
||||||
|
toast.success(saved ? "Automatic backup on" : "Automatic backup off");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not change automatic backup", String(e));
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFolder = async (path: string) => {
|
||||||
|
try {
|
||||||
|
await api.openFile(path);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not open the folder", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = status?.pendingRestore ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="voiced-stack">
|
||||||
|
{pending ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="warning"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="A restore is waiting for a restart"
|
||||||
|
subtitle={`${pending.backupName} (${pending.fileCount} files, ${formatBytes(pending.totalBytes)}) has been checked. Restart Voiced to switch to it; your current data is kept in a safety copy.`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{pending ? (
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" onClick={() => void restart()} disabled={restarting}>
|
||||||
|
{restarting ? "Restarting…" : "Restart now"}
|
||||||
|
</Button>
|
||||||
|
<Button size="md" kind="tertiary" onClick={() => void cancelPending()} disabled={restarting}>
|
||||||
|
Cancel restore
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Back up</h2>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Saves one zip with your invoices, clients, payments, settings, logo and signature images, archived PDFs and imported
|
||||||
|
fonts. Keep a copy somewhere other than this computer.
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" onClick={() => void backUpNow()} disabled={backingUp}>
|
||||||
|
{backingUp ? "Backing up…" : "Back up now"}
|
||||||
|
</Button>
|
||||||
|
{backingUp ? <InlineLoading description="Writing the backup" /> : null}
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Restore from backup</h2>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first,
|
||||||
|
never deleted.
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" kind="danger--tertiary" onClick={() => void chooseRestoreFile()} disabled={restorePath !== null || !!pending}>
|
||||||
|
Restore from backup…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{status?.lastRestore ? (
|
||||||
|
<p className="voiced-muted" role="status">
|
||||||
|
{status.lastRestore.ok
|
||||||
|
? `Last restore: ${status.lastRestore.backupName || "a backup"}, ${formatWhen(status.lastRestore.at)}.`
|
||||||
|
: `The last restore attempt (${formatWhen(status.lastRestore.at)}) did not go through: ${status.lastRestore.message}`}{" "}
|
||||||
|
{status.lastRestore.safetyDir ? (
|
||||||
|
<Button kind="ghost" size="sm" onClick={() => void api.revealInFolder(status.lastRestore!.safetyDir!).catch((e) => toast.error("Could not open the folder", String(e)))}>
|
||||||
|
Show safety copy
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Automatic backup</h2>
|
||||||
|
<Toggle
|
||||||
|
id="autoBackup"
|
||||||
|
labelText="Back up automatically every day"
|
||||||
|
labelA="Off"
|
||||||
|
labelB="On"
|
||||||
|
toggled={status?.autoBackup ?? true}
|
||||||
|
disabled={!status}
|
||||||
|
onToggle={(checked: boolean) => void toggleAuto(checked)}
|
||||||
|
/>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
While Voiced is open it writes one backup a day into its data folder and keeps the newest 14. This changes right away; you
|
||||||
|
do not need to click Save settings.
|
||||||
|
</p>
|
||||||
|
<p role="status">
|
||||||
|
{status === null
|
||||||
|
? "Loading…"
|
||||||
|
: status.lastAutoBackup
|
||||||
|
? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).`
|
||||||
|
: "No automatic backup yet."}
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" kind="tertiary" disabled={!status} onClick={() => status && void openFolder(status.autoDir)}>
|
||||||
|
Open backup folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Setup wizard</h2>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Run the first-time setup again to change your business details, logo, bank account and numbering in one go. Existing
|
||||||
|
invoices, clients and backups are not touched, and invoice numbering carries on unless you change the prefix.
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" kind="tertiary" onClick={() => void rerunSetup()}>
|
||||||
|
Run setup again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={restorePath !== null}
|
||||||
|
danger={step === "confirm"}
|
||||||
|
modalHeading={step === "ready" ? "Restart to finish restoring" : "Restore from this backup?"}
|
||||||
|
primaryButtonText={
|
||||||
|
step === "confirm" ? "Check and restore" : step === "staging" ? "Checking the backup…" : restarting ? "Restarting…" : "Restart now"
|
||||||
|
}
|
||||||
|
secondaryButtonText={step === "ready" ? "Later" : "Cancel"}
|
||||||
|
primaryButtonDisabled={step === "staging" || restarting}
|
||||||
|
onRequestClose={closeRestore}
|
||||||
|
onRequestSubmit={() => void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
|
||||||
|
onSecondarySubmit={closeRestore}
|
||||||
|
>
|
||||||
|
<div className="voiced-stack">
|
||||||
|
{restoreError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Cannot restore" subtitle={restoreError} /> : null}
|
||||||
|
{step === "ready" && staged ? (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<strong>{staged.backupName}</strong> passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
|
||||||
|
{formatWhen(staged.backupCreatedAt)}.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
|
||||||
|
when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
File: <strong>{restorePath}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
|
||||||
|
contents of this backup.
|
||||||
|
</p>
|
||||||
|
<ul className="voiced-list">
|
||||||
|
<li>The file is checked first; a damaged or newer-version backup is refused and nothing changes.</li>
|
||||||
|
<li>Your current data is moved to a safety copy in Voiced’s backups folder before the swap. It is never deleted.</li>
|
||||||
|
<li>Voiced restarts to complete the restore.</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
InvoiceInput,
|
||||||
InvoiceSeries,
|
InvoiceSeries,
|
||||||
InvoiceSummary,
|
InvoiceSummary,
|
||||||
|
ItemPreset,
|
||||||
Settings,
|
Settings,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { LogoAsset } from "./logo";
|
import type { LogoAsset } from "./logo";
|
||||||
|
import type { Payment, PaymentInput } from "./payments";
|
||||||
|
import type { BackupStatus, BackupSummary, PendingRestore } from "./backup";
|
||||||
|
import type { LedgerRow } from "./historyExport";
|
||||||
|
import type {
|
||||||
|
ErpnextConfig,
|
||||||
|
ErpnextConfigInput,
|
||||||
|
ErpnextConnectionTest,
|
||||||
|
ErpnextOptions,
|
||||||
|
ErpnextPaymentPushResult,
|
||||||
|
ErpnextPushResult,
|
||||||
|
ErpnextSyncStatus,
|
||||||
|
} from "./erpnext";
|
||||||
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
||||||
|
|
||||||
export interface ArchiveStatus {
|
export interface ArchiveStatus {
|
||||||
@@ -36,14 +49,23 @@ export const api = {
|
|||||||
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
|
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
|
||||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||||
|
|
||||||
|
listItemPresets: () => invoke<ItemPreset[]>("list_item_presets"),
|
||||||
|
saveItemPreset: (preset: ItemPreset) => invoke<ItemPreset>("save_item_preset", { preset }),
|
||||||
|
deleteItemPreset: (id: number) => invoke<void>("delete_item_preset", { id }),
|
||||||
|
|
||||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||||
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
||||||
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
||||||
cancelInvoice: (id: number, reason: string) =>
|
cancelInvoice: (id: number, reason: string) =>
|
||||||
invoke<Invoice>("cancel_invoice", { id, reason }),
|
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||||
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
||||||
|
listInvoiceLedger: () => invoke<LedgerRow[]>("list_invoice_ledger"),
|
||||||
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
||||||
|
|
||||||
|
recordPayment: (input: PaymentInput) => invoke<Payment>("record_payment", { input }),
|
||||||
|
listPayments: (invoiceId: number) => invoke<Payment[]>("list_payments", { invoiceId }),
|
||||||
|
deletePayment: (id: number) => invoke<void>("delete_payment", { id }),
|
||||||
|
|
||||||
saveDraft: (id: number | null, payload: unknown) =>
|
saveDraft: (id: number | null, payload: unknown) =>
|
||||||
invoke<number>("save_draft", { id, payload }),
|
invoke<number>("save_draft", { id, payload }),
|
||||||
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
|
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
|
||||||
@@ -92,4 +114,32 @@ export const api = {
|
|||||||
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||||
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
||||||
openFile: (path: string) => invoke<void>("open_file", { path }),
|
openFile: (path: string) => invoke<void>("open_file", { path }),
|
||||||
|
|
||||||
|
// Backup and restore. A restore is staged now and applied by the next start (see commands/backup.rs).
|
||||||
|
createBackup: (dest: string) => invoke<BackupSummary>("create_backup", { dest }),
|
||||||
|
restoreBackup: (path: string) => invoke<PendingRestore>("restore_backup", { path }),
|
||||||
|
cancelPendingRestore: () => invoke<void>("cancel_pending_restore"),
|
||||||
|
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
|
||||||
|
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
|
||||||
|
restartApp: () => invoke<void>("restart_app"),
|
||||||
|
|
||||||
|
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
|
||||||
|
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
|
||||||
|
erpnextSaveConfig: (config: ErpnextConfigInput) => invoke<ErpnextConfig>("erpnext_save_config", { config }),
|
||||||
|
erpnextTestConnection: (config: ErpnextConfigInput) =>
|
||||||
|
invoke<ErpnextConnectionTest>("erpnext_test_connection", { config }),
|
||||||
|
erpnextLoadOptions: (config: ErpnextConfigInput) => invoke<ErpnextOptions>("erpnext_load_options", { config }),
|
||||||
|
/** `submit` omitted follows the "submit on push" setting. A failed push resolves with `ok: false`. */
|
||||||
|
erpnextPushInvoice: (id: number, submit?: boolean) =>
|
||||||
|
invoke<ErpnextPushResult>("erpnext_push_invoice", { id, submit: submit ?? null }),
|
||||||
|
/** One result per distinct id, in order; a failing row never stops the rest. */
|
||||||
|
erpnextPushInvoices: (ids: number[], submit?: boolean) =>
|
||||||
|
invoke<ErpnextPushResult[]>("erpnext_push_invoices", { ids, submit: submit ?? null }),
|
||||||
|
erpnextPushPayment: (paymentId: number) =>
|
||||||
|
invoke<ErpnextPaymentPushResult>("erpnext_push_payment", { paymentId }),
|
||||||
|
erpnextSyncStatus: (id: number) => invoke<ErpnextSyncStatus>("erpnext_sync_status", { id }),
|
||||||
|
/** Every invoice that has a sync row (invoices never pushed are absent). */
|
||||||
|
erpnextSyncStatuses: () => invoke<ErpnextSyncStatus[]>("erpnext_sync_statuses"),
|
||||||
|
/** `<base>/app/sales-invoice/<name>`; rejects when the invoice was never sent. */
|
||||||
|
erpnextOpenUrl: (id: number) => invoke<string>("erpnext_open_url", { id }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { backupFileName, ensureZipExtension, formatBytes, formatWhen } from "./backup";
|
||||||
|
|
||||||
|
describe("backup helpers", () => {
|
||||||
|
it("names backups by local date and time", () => {
|
||||||
|
expect(backupFileName(new Date(2026, 2, 4, 9, 5))).toBe("voiced-backup-20260304-0905.zip");
|
||||||
|
});
|
||||||
|
it("adds the zip extension only when missing", () => {
|
||||||
|
expect(ensureZipExtension("/a/b")).toBe("/a/b.zip");
|
||||||
|
expect(ensureZipExtension("/a/b.ZIP")).toBe("/a/b.ZIP");
|
||||||
|
expect(ensureZipExtension("/a/b.zip")).toBe("/a/b.zip");
|
||||||
|
});
|
||||||
|
it("formats sizes", () => {
|
||||||
|
expect(formatBytes(512)).toBe("512 B");
|
||||||
|
expect(formatBytes(1536)).toBe("1.5 KB");
|
||||||
|
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
|
||||||
|
expect(formatBytes(300 * 1024 * 1024)).toBe("300 MB");
|
||||||
|
});
|
||||||
|
it("falls back to the raw text for an unparseable time", () => {
|
||||||
|
expect(formatWhen("not a date")).toBe("not a date");
|
||||||
|
expect(formatWhen("2026-03-04T10:00:00Z")).toMatch(/2026/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/** Shapes returned by the backup commands (src-tauri/src/commands/backup.rs). */
|
||||||
|
export interface BackupSummary {
|
||||||
|
path: string;
|
||||||
|
createdAt: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
fileCount: number;
|
||||||
|
totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingRestore {
|
||||||
|
backupName: string;
|
||||||
|
backupCreatedAt: string;
|
||||||
|
appVersion: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
fileCount: number;
|
||||||
|
totalBytes: number;
|
||||||
|
stagedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastRestore {
|
||||||
|
ok: boolean;
|
||||||
|
at: string;
|
||||||
|
backupName: string;
|
||||||
|
message: string;
|
||||||
|
safetyDir: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupStatus {
|
||||||
|
autoBackup: boolean;
|
||||||
|
autoDir: string;
|
||||||
|
lastAutoBackup: string | null;
|
||||||
|
autoBackupCount: number;
|
||||||
|
pendingRestore: PendingRestore | null;
|
||||||
|
lastRestore: LastRestore | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
/** `voiced-backup-20260304-1530.zip` in local time. */
|
||||||
|
export function backupFileName(now: Date = new Date()): string {
|
||||||
|
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
||||||
|
return `voiced-backup-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The save dialog does not always add the extension (notably on Linux). */
|
||||||
|
export function ensureZipExtension(path: string): string {
|
||||||
|
return /\.zip$/i.test(path) ? path : `${path}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1536 -> "1.5 KB". Sizes of backup contents are display-only, so plain division is fine. */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let i = 0;
|
||||||
|
while (value >= 1024 && i < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "4 Mar 2026, 3:30 pm" for an ISO timestamp; the raw text if it does not parse. */
|
||||||
|
export function formatWhen(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { composeAddress, pincodeError, splitLegacyAddress } from "./clientAddress";
|
||||||
|
|
||||||
|
describe("composeAddress", () => {
|
||||||
|
it("joins lines and the city/state/pincode line", () => {
|
||||||
|
expect(
|
||||||
|
composeAddress({ line1: "12 MG Road", line2: "Floor 2", city: "Bengaluru", stateCode: "29", pincode: "560001" }),
|
||||||
|
).toBe("12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
||||||
|
});
|
||||||
|
it("skips empty parts", () => {
|
||||||
|
expect(composeAddress({ line1: " A ", line2: "", city: "", stateCode: "", pincode: "560001" })).toBe("A\n560001");
|
||||||
|
expect(composeAddress({ line1: "", line2: "", city: "Pune", stateCode: "", pincode: "" })).toBe("Pune");
|
||||||
|
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "29", pincode: "" })).toBe("Karnataka");
|
||||||
|
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "", pincode: "" })).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitLegacyAddress", () => {
|
||||||
|
it("puts the first line in line1 and the rest in line2", () => {
|
||||||
|
expect(splitLegacyAddress("Old style\nfree text\r\nBangalore")).toEqual({
|
||||||
|
line1: "Old style",
|
||||||
|
line2: "free text, Bangalore",
|
||||||
|
});
|
||||||
|
expect(splitLegacyAddress("")).toEqual({ line1: "", line2: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pincodeError", () => {
|
||||||
|
it("accepts empty and 6 digits only", () => {
|
||||||
|
expect(pincodeError("")).toBeUndefined();
|
||||||
|
expect(pincodeError("560001")).toBeUndefined();
|
||||||
|
expect(pincodeError("56001")).toBeDefined();
|
||||||
|
expect(pincodeError("56000A")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
|
||||||
|
import type {
|
||||||
|
ErpnextConfig,
|
||||||
|
ErpnextConfigInput,
|
||||||
|
ErpnextConnectionTest,
|
||||||
|
ErpnextOptionItem,
|
||||||
|
ErpnextOptions,
|
||||||
|
ErpnextPaymentPushResult,
|
||||||
|
ErpnextPushResult,
|
||||||
|
ErpnextSyncStatus,
|
||||||
|
ErpnextWarning,
|
||||||
|
} from "./erpnext";
|
||||||
|
|
||||||
|
export type TagColor = "red" | "green" | "blue" | "gray" | "purple" | "cool-gray" | "warm-gray";
|
||||||
|
export type NoticeKind = "success" | "info" | "warning" | "error";
|
||||||
|
|
||||||
|
export const SETTINGS_LINK_TEXT = "Settings, Integrations";
|
||||||
|
|
||||||
|
// ---- configuration state ----
|
||||||
|
|
||||||
|
/** Enough saved to try a push: address, key and a stored secret. */
|
||||||
|
export function isConfigured(cfg: Pick<ErpnextConfig, "baseUrl" | "apiKey" | "apiSecretSet"> | null | undefined): boolean {
|
||||||
|
return Boolean(cfg && cfg.baseUrl.trim() && cfg.apiKey.trim() && cfg.apiSecretSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a connection test result is stored (the backend refuses a registered vendor's push without one). */
|
||||||
|
export function hasDetectResult(result: ErpnextConnectionTest | null | undefined): boolean {
|
||||||
|
return Boolean(result && typeof result === "object" && Object.keys(result).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NOT_CONFIGURED_REASON = "ERPNext is not set up. Enter the address, API key and secret in Settings, Integrations.";
|
||||||
|
export const RUN_TEST_REASON = "Run Test connection first (Settings, Integrations).";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why an invoice cannot be sent right now, or null. The backend applies the same registered-vendor rule using the
|
||||||
|
* supplier snapshot frozen on the invoice; the current GST registration setting is the proxy here.
|
||||||
|
*/
|
||||||
|
export function pushDisabledReason(opts: {
|
||||||
|
cfg: ErpnextConfig | null;
|
||||||
|
vendorRegistered: boolean;
|
||||||
|
invoiceStatus?: string;
|
||||||
|
}): string | null {
|
||||||
|
if (!isConfigured(opts.cfg)) return NOT_CONFIGURED_REASON;
|
||||||
|
if (opts.invoiceStatus !== undefined && opts.invoiceStatus !== "issued") {
|
||||||
|
return opts.invoiceStatus === "cancelled"
|
||||||
|
? "Cancelled invoices are never sent to ERPNext."
|
||||||
|
: "Only issued invoices are sent to ERPNext.";
|
||||||
|
}
|
||||||
|
if (opts.vendorRegistered && !hasDetectResult(opts.cfg?.lastDetectResult)) return RUN_TEST_REASON;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A payment can be posted only against a submitted invoice, once. */
|
||||||
|
export function paymentPushDisabledReason(opts: {
|
||||||
|
cfg: ErpnextConfig | null;
|
||||||
|
sync: ErpnextSyncStatus | undefined;
|
||||||
|
entry: string | null | undefined;
|
||||||
|
}): string | null {
|
||||||
|
if (!isConfigured(opts.cfg)) return NOT_CONFIGURED_REASON;
|
||||||
|
if (opts.entry) return `Already sent as ${opts.entry}.`;
|
||||||
|
if (!opts.sync || opts.sync.status !== "synced") return "Send the invoice to ERPNext first.";
|
||||||
|
if (opts.sync.remoteDocstatus !== 1) return "Submit the invoice in ERPNext first.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sync status ----
|
||||||
|
|
||||||
|
export interface SyncTag {
|
||||||
|
label: string;
|
||||||
|
type: TagColor;
|
||||||
|
/** Hover text: the stored error for a failure. */
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncTag(s: ErpnextSyncStatus | undefined): SyncTag {
|
||||||
|
if (!s || s.status === "none") return { label: "Not sent", type: "gray" };
|
||||||
|
switch (s.status) {
|
||||||
|
case "synced":
|
||||||
|
return s.remoteDocstatus === 1
|
||||||
|
? { label: "Submitted", type: "green", tooltip: s.remoteName || undefined }
|
||||||
|
: { label: "Draft in ERPNext", type: "blue", tooltip: s.remoteName || undefined };
|
||||||
|
case "error":
|
||||||
|
return { label: "Error", type: "red", tooltip: s.lastError || "The last send failed." };
|
||||||
|
case "conflict":
|
||||||
|
return { label: "Conflict", type: "purple", tooltip: s.lastError || "ERPNext already has a different document with this name." };
|
||||||
|
case "pending":
|
||||||
|
return { label: "Sending", type: "warm-gray" };
|
||||||
|
default:
|
||||||
|
return { label: "Not sent", type: "gray" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function indexSyncStatuses(list: ErpnextSyncStatus[]): Map<number, ErpnextSyncStatus> {
|
||||||
|
return new Map(list.map((s) => [s.invoiceId, s]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows that have an ERPNext document to open. */
|
||||||
|
export const canOpenInErpnext = (s: ErpnextSyncStatus | undefined): boolean => Boolean(s && s.remoteName);
|
||||||
|
|
||||||
|
/** Stored sync time (RFC 3339) for display; the raw text if it cannot be parsed, blank if there is none. */
|
||||||
|
export function formatSyncedAt(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString("en-GB", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** From a selection: the issued invoices that can be sent, and how many were left out (cancelled or draft). */
|
||||||
|
export function sendableSelection<T extends { status: string }>(rows: T[]): { send: T[]; skipped: number } {
|
||||||
|
const send = rows.filter((r) => r.status === "issued");
|
||||||
|
return { send, skipped: rows.length - send.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- push results ----
|
||||||
|
|
||||||
|
export interface Notice {
|
||||||
|
kind: NoticeKind;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docState = (docstatus: number) => (docstatus === 1 ? "submitted" : "draft");
|
||||||
|
|
||||||
|
/** One push outcome as a toast. Warnings turn a success into a warning toast so they are read. */
|
||||||
|
export function describePush(r: ErpnextPushResult): Notice {
|
||||||
|
if (r.ok) {
|
||||||
|
const where = `${r.remoteName} (${docState(r.remoteDocstatus)})`;
|
||||||
|
if (r.warnings.length > 0) {
|
||||||
|
return { kind: "warning", title: `Invoice ${r.number} sent to ERPNext with notes`, subtitle: [where, ...r.warnings].join(" ") };
|
||||||
|
}
|
||||||
|
if (r.noOp) return { kind: "info", title: `Invoice ${r.number} is already up to date in ERPNext`, subtitle: where };
|
||||||
|
return { kind: "success", title: `Invoice ${r.number} sent to ERPNext`, subtitle: where };
|
||||||
|
}
|
||||||
|
if (r.status === "refused" || r.errorKind === "precondition" || r.errorKind === "config") {
|
||||||
|
return { kind: "warning", title: `Invoice ${r.number || r.invoiceId} was not sent`, subtitle: r.error ?? undefined };
|
||||||
|
}
|
||||||
|
if (r.status === "conflict") {
|
||||||
|
return { kind: "error", title: `Invoice ${r.number} conflicts with ERPNext`, subtitle: r.error ?? undefined };
|
||||||
|
}
|
||||||
|
return { kind: "error", title: `Could not send invoice ${r.number || r.invoiceId}`, subtitle: r.error ?? undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describePaymentPush(r: ErpnextPaymentPushResult): Notice {
|
||||||
|
if (r.ok) {
|
||||||
|
return r.alreadySynced
|
||||||
|
? { kind: "info", title: "Payment was already sent", subtitle: r.entryName ?? undefined }
|
||||||
|
: { kind: "success", title: "Payment sent to ERPNext", subtitle: r.entryName ?? undefined };
|
||||||
|
}
|
||||||
|
return { kind: "error", title: "Could not send the payment", subtitle: r.error ?? undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A rejected invoke (for example "ERPNext is not set up yet") turned into a result row, so a batch carries on. */
|
||||||
|
export function failedPush(invoiceId: number, number: string, error: unknown): ErpnextPushResult {
|
||||||
|
return {
|
||||||
|
invoiceId,
|
||||||
|
number,
|
||||||
|
ok: false,
|
||||||
|
status: "error",
|
||||||
|
remoteName: "",
|
||||||
|
remoteDocstatus: 0,
|
||||||
|
created: false,
|
||||||
|
noOp: false,
|
||||||
|
attached: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
errorKind: "other",
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchSummary {
|
||||||
|
total: number;
|
||||||
|
sent: number;
|
||||||
|
upToDate: number;
|
||||||
|
failed: number;
|
||||||
|
withNotes: number;
|
||||||
|
kind: NoticeKind;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeBatch(results: ErpnextPushResult[]): BatchSummary {
|
||||||
|
const failed = results.filter((r) => !r.ok).length;
|
||||||
|
const upToDate = results.filter((r) => r.ok && r.noOp && r.warnings.length === 0).length;
|
||||||
|
const sent = results.filter((r) => r.ok).length - upToDate;
|
||||||
|
const withNotes = results.filter((r) => r.ok && r.warnings.length > 0).length;
|
||||||
|
const kind: NoticeKind = failed > 0 ? (failed === results.length ? "error" : "warning") : withNotes > 0 ? "warning" : "success";
|
||||||
|
const parts = [`${sent} sent`];
|
||||||
|
if (upToDate > 0) parts.push(`${upToDate} already up to date`);
|
||||||
|
if (failed > 0) parts.push(`${failed} failed`);
|
||||||
|
return { total: results.length, sent, upToDate, failed, withNotes, kind, title: parts.join(", ") };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- auto-push after issue ----
|
||||||
|
|
||||||
|
export interface AutoPushDeps {
|
||||||
|
getConfig(): Promise<ErpnextConfig>;
|
||||||
|
push(id: number, submit?: boolean): Promise<ErpnextPushResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes a just-issued invoice when "send on issue" is on. Never throws and never blocks the issue: anything that
|
||||||
|
* goes wrong comes back as a notice. Returns null when nothing was attempted (off, or not set up).
|
||||||
|
*/
|
||||||
|
export async function autoPushIssued(
|
||||||
|
deps: AutoPushDeps,
|
||||||
|
invoice: { id: number; number: string },
|
||||||
|
vendorRegistered: boolean,
|
||||||
|
): Promise<Notice | null> {
|
||||||
|
let cfg: ErpnextConfig;
|
||||||
|
try {
|
||||||
|
cfg = await deps.getConfig();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!cfg.autoPushOnIssue || !isConfigured(cfg)) return null;
|
||||||
|
const blocked = pushDisabledReason({ cfg, vendorRegistered });
|
||||||
|
if (blocked) return { kind: "warning", title: `Invoice ${invoice.number} was not sent to ERPNext`, subtitle: blocked };
|
||||||
|
try {
|
||||||
|
return describePush(await deps.push(invoice.id));
|
||||||
|
} catch (e) {
|
||||||
|
return describePush(failedPush(invoice.id, invoice.number, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- connection test display ----
|
||||||
|
|
||||||
|
const ERROR_CODES = new Set([
|
||||||
|
"company_missing",
|
||||||
|
"company_address_missing",
|
||||||
|
"income_account_missing",
|
||||||
|
"tax_accounts_missing",
|
||||||
|
"naming_series_missing",
|
||||||
|
"naming_series_too_long",
|
||||||
|
"number_invalid",
|
||||||
|
"mirror_unsupported",
|
||||||
|
"gst_accounts_not_configured",
|
||||||
|
]);
|
||||||
|
const INFO_CODES = new Set(["versions_unknown", "gst_settings_unreadable"]);
|
||||||
|
|
||||||
|
/** How loudly a test warning is shown: the ones that would make invoices land wrong are errors. */
|
||||||
|
export function warningSeverity(code: string): "error" | "warning" | "info" {
|
||||||
|
if (ERROR_CODES.has(code)) return "error";
|
||||||
|
if (INFO_CODES.has(code)) return "info";
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortWarnings(list: ErpnextWarning[]): ErpnextWarning[] {
|
||||||
|
const rank = { error: 0, warning: 1, info: 2 } as const;
|
||||||
|
return [...list].sort((a, b) => rank[warningSeverity(a.code)] - rank[warningSeverity(b.code)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionsLine(test: ErpnextConnectionTest): string {
|
||||||
|
const v = test.versions;
|
||||||
|
const parts = [
|
||||||
|
`Frappe ${v.frappe ?? "unknown"}`,
|
||||||
|
`ERPNext ${v.erpnext ?? "unknown"}`,
|
||||||
|
test.indiaCompliance ? `India Compliance ${v.indiaCompliance ?? "installed"}` : "India Compliance not detected",
|
||||||
|
];
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- settings form ----
|
||||||
|
|
||||||
|
export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const;
|
||||||
|
export const DEFAULT_UOM_MAP: Record<string, string> = {
|
||||||
|
second: "Second",
|
||||||
|
minute: "Minute",
|
||||||
|
hour: "Hour",
|
||||||
|
session: "Nos",
|
||||||
|
unit: "Nos",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
|
||||||
|
const out: Record<string, string> = { ...DEFAULT_UOM_MAP };
|
||||||
|
for (const [k, v] of Object.entries(map ?? {})) if (v.trim()) out[k] = v;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the form edits: the saved config minus the write-only fields, plus the secret being typed. */
|
||||||
|
export function toInput(cfg: ErpnextConfig): ErpnextConfigInput {
|
||||||
|
const { apiSecretSet: _set, lastDetectResult: _last, ...rest } = cfg;
|
||||||
|
void _set;
|
||||||
|
void _last;
|
||||||
|
return { ...rest, uomMap: withDefaultUoms(rest.uomMap), apiSecret: "", clearSecret: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror mode needs a Frappe that keeps a posted name; an untested site defaults to it (the backend default). */
|
||||||
|
export function mirrorSupported(test: ErpnextConnectionTest | null): boolean {
|
||||||
|
return test ? test.features.v2Naming : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalDevHost(host: string): boolean {
|
||||||
|
const h = host.replace(/\.$/, "").toLowerCase();
|
||||||
|
return h === "localhost" || h === "127.0.0.1" || h.endsWith(".localhost") || h.endsWith(".test");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same rules as the backend's address check, so a bad address is caught before the round trip. */
|
||||||
|
export function baseUrlProblem(raw: string): string | null {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!text) return "Enter the address of your ERPNext site.";
|
||||||
|
const withScheme = text.includes("://") ? text : `${isLocalDevHost(text.split(/[/:]/)[0] ?? "") ? "http" : "https"}://${text}`;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(withScheme);
|
||||||
|
} catch {
|
||||||
|
return "This is not a valid address, e.g. https://erp.example.com";
|
||||||
|
}
|
||||||
|
if (url.protocol !== "https:" && url.protocol !== "http:") return "Use an https:// address.";
|
||||||
|
if (url.protocol === "http:" && !isLocalDevHost(url.hostname)) {
|
||||||
|
return "Use an https:// address. Plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test.";
|
||||||
|
}
|
||||||
|
if (url.username || url.password) return "Do not put credentials in the address; use the API key and secret fields.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormProblems {
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
apiSecret?: string;
|
||||||
|
company?: string;
|
||||||
|
namingSeries?: string;
|
||||||
|
namingMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problems that stop a Test connection (address, key, secret). */
|
||||||
|
export function connectionProblems(form: ErpnextConfigInput, secretStored: boolean): FormProblems {
|
||||||
|
const p: FormProblems = {};
|
||||||
|
const url = baseUrlProblem(form.baseUrl);
|
||||||
|
if (url) p.baseUrl = url;
|
||||||
|
if (!form.apiKey.trim()) p.apiKey = "Enter the API key.";
|
||||||
|
if (!form.apiSecret.trim() && (form.clearSecret || !secretStored)) p.apiSecret = "Enter the API secret.";
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problems that stop a Save. An empty form (nothing set up) saves fine; a half-filled one does not. */
|
||||||
|
export function validateConfigForm(form: ErpnextConfigInput, secretStored: boolean, test: ErpnextConnectionTest | null): FormProblems {
|
||||||
|
const touched = form.baseUrl.trim() || form.apiKey.trim() || form.apiSecret.trim() || secretStored;
|
||||||
|
if (!touched) return {};
|
||||||
|
const p = connectionProblems(form, secretStored);
|
||||||
|
if (form.namingMode === "series" && !form.namingSeries.trim()) p.namingSeries = "Choose or enter the naming series.";
|
||||||
|
if (form.namingMode === "mirror" && !mirrorSupported(test)) {
|
||||||
|
p.namingMode = "This ERPNext version does not keep a posted invoice number. Use ERPNext series.";
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasProblems = (p: FormProblems): boolean => Object.keys(p).length > 0;
|
||||||
|
|
||||||
|
/** Options for a dropdown, with the saved value kept visible when the loaded list does not contain it. */
|
||||||
|
export function withStoredValue(items: ErpnextOptionItem[], stored: string): ErpnextOptionItem[] {
|
||||||
|
const value = stored.trim();
|
||||||
|
if (!value || items.some((i) => i.name === value)) return items;
|
||||||
|
return [{ name: value, label: value, detail: "saved value, not in the loaded list" }, ...items];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const optionText = (i: ErpnextOptionItem): string => (i.detail ? `${i.label} (${i.detail})` : i.label);
|
||||||
|
|
||||||
|
export const OPTION_LIST_LABELS: Record<string, string> = {
|
||||||
|
companies: "Companies",
|
||||||
|
companyAddresses: "Company addresses",
|
||||||
|
incomeAccounts: "Income accounts",
|
||||||
|
taxAccounts: "Tax accounts",
|
||||||
|
taxTemplates: "Tax templates",
|
||||||
|
namingSeries: "Naming series",
|
||||||
|
itemGroups: "Item groups",
|
||||||
|
uoms: "Units of measure",
|
||||||
|
costCenters: "Cost centers",
|
||||||
|
priceLists: "Price lists",
|
||||||
|
customerGroups: "Customer groups",
|
||||||
|
territories: "Territories",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-list load errors keyed by list name; the other lists stay usable. */
|
||||||
|
export function optionErrors(options: ErpnextOptions | null): Map<string, string> {
|
||||||
|
return new Map((options?.errors ?? []).map((e) => [e.list, e.message]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyOptions = (): ErpnextOptions => ({
|
||||||
|
companies: [],
|
||||||
|
companyAddresses: [],
|
||||||
|
incomeAccounts: [],
|
||||||
|
taxAccounts: [],
|
||||||
|
taxTemplates: [],
|
||||||
|
namingSeries: [],
|
||||||
|
itemGroups: [],
|
||||||
|
uoms: [],
|
||||||
|
costCenters: [],
|
||||||
|
priceLists: [],
|
||||||
|
customerGroups: [],
|
||||||
|
territories: [],
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { currentFyStartYear, fyLabel, fyRange, fyStartYear, monthOf, todayIst, FY_MONTHS } from "./fiscal";
|
||||||
|
|
||||||
|
describe("financial year", () => {
|
||||||
|
it("starts on 1 April", () => {
|
||||||
|
expect(fyStartYear("2026-03-31")).toBe(2025);
|
||||||
|
expect(fyStartYear("2026-04-01")).toBe(2026);
|
||||||
|
expect(fyStartYear("2026-12-31")).toBe(2026);
|
||||||
|
expect(fyStartYear("2027-01-01")).toBe(2026);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects things that are not ISO dates", () => {
|
||||||
|
expect(fyStartYear("")).toBeNull();
|
||||||
|
expect(fyStartYear("01/04/2026")).toBeNull();
|
||||||
|
expect(fyStartYear("2026-13-01")).toBeNull();
|
||||||
|
expect(monthOf("2026-00-10")).toBeNull();
|
||||||
|
expect(monthOf("2026-07-10")).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels and ranges", () => {
|
||||||
|
expect(fyLabel(2025)).toBe("FY 2025-26");
|
||||||
|
expect(fyLabel(2099)).toBe("FY 2099-00");
|
||||||
|
expect(fyRange(2025)).toEqual({ from: "2025-04-01", to: "2026-03-31" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists months April to March", () => {
|
||||||
|
expect(FY_MONTHS.map((m) => m.month)).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3]);
|
||||||
|
expect(FY_MONTHS[0].label).toBe("April");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses IST for now, not the machine time zone", () => {
|
||||||
|
// 20:00 UTC on 31 March is already 01:30 on 1 April in India: a new financial year.
|
||||||
|
const now = new Date("2026-03-31T20:00:00Z");
|
||||||
|
expect(todayIst(now)).toBe("2026-04-01");
|
||||||
|
expect(currentFyStartYear(now)).toBe(2026);
|
||||||
|
// 18:29 UTC is still 23:59 on 31 March in India.
|
||||||
|
expect(currentFyStartYear(new Date("2026-03-31T18:29:00Z"))).toBe(2025);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** Indian financial year (April to March) helpers. Invoice dates are plain YYYY-MM-DD calendar dates, so they
|
||||||
|
* are read from the string; only "now" needs a time zone, and that is always IST (UTC+05:30). */
|
||||||
|
|
||||||
|
const ISO = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||||
|
const IST_OFFSET_MS = (5 * 60 + 30) * 60_000;
|
||||||
|
|
||||||
|
/** Months in financial-year order, for the month filter. */
|
||||||
|
export const FY_MONTHS: { month: number; label: string }[] = [4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3].map((month) => ({
|
||||||
|
month,
|
||||||
|
label: new Date(2000, month - 1, 1).toLocaleDateString("en-GB", { month: "long" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Month 1-12 of an ISO date, or null when it is not one. */
|
||||||
|
export function monthOf(iso: string): number | null {
|
||||||
|
const m = ISO.exec(iso);
|
||||||
|
if (!m) return null;
|
||||||
|
const month = Number(m[2]);
|
||||||
|
return month >= 1 && month <= 12 ? month : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The calendar year in which the financial year containing `iso` starts (2026-03-31 -> 2025, 2026-04-01 -> 2026). */
|
||||||
|
export function fyStartYear(iso: string): number | null {
|
||||||
|
const m = ISO.exec(iso);
|
||||||
|
const month = monthOf(iso);
|
||||||
|
if (!m || month === null) return null;
|
||||||
|
const year = Number(m[1]);
|
||||||
|
return month >= 4 ? year : year - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2025 -> "FY 2025-26". */
|
||||||
|
export function fyLabel(startYear: number): string {
|
||||||
|
return `FY ${startYear}-${String((startYear + 1) % 100).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First and last day of a financial year. */
|
||||||
|
export function fyRange(startYear: number): { from: string; to: string } {
|
||||||
|
return { from: `${startYear}-04-01`, to: `${startYear + 1}-03-31` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's date in IST as YYYY-MM-DD. */
|
||||||
|
export function todayIst(now: Date = new Date()): string {
|
||||||
|
return new Date(now.getTime() + IST_OFFSET_MS).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The financial year that `now` falls in, in IST. */
|
||||||
|
export function currentFyStartYear(now: Date = new Date()): number {
|
||||||
|
return fyStartYear(todayIst(now)) as number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildHistoryRows,
|
||||||
|
csvField,
|
||||||
|
exportHistory,
|
||||||
|
guardFormula,
|
||||||
|
HISTORY_COLUMNS,
|
||||||
|
historyExportName,
|
||||||
|
paiseToDecimal,
|
||||||
|
toCsv,
|
||||||
|
toJson,
|
||||||
|
type HistoryExportDeps,
|
||||||
|
type LedgerRow,
|
||||||
|
} from "./historyExport";
|
||||||
|
import { NO_FILTERS } from "./historyFilter";
|
||||||
|
import type { InvoiceSummary } from "./types";
|
||||||
|
|
||||||
|
const summary = (over: Partial<InvoiceSummary> = {}): InvoiceSummary => ({
|
||||||
|
id: 1,
|
||||||
|
number: "INV/2025-001",
|
||||||
|
invoiceDate: "2025-06-10",
|
||||||
|
dueDate: "2025-07-10",
|
||||||
|
clientName: "Acme, Inc.",
|
||||||
|
total: 0,
|
||||||
|
status: "issued",
|
||||||
|
createdAt: "",
|
||||||
|
poNumber: "PO-1",
|
||||||
|
totalPaise: 1_180_050,
|
||||||
|
paidPaise: 1_000_000,
|
||||||
|
tdsPaise: 50_000,
|
||||||
|
balancePaise: 130_050,
|
||||||
|
paymentStatus: "partially_paid",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ledger = (over: Partial<LedgerRow> = {}): LedgerRow => ({
|
||||||
|
id: 1,
|
||||||
|
clientGstin: "29ABCDE1234F1Z5",
|
||||||
|
placeOfSupplyCode: "29",
|
||||||
|
docType: "tax_invoice",
|
||||||
|
hsnSac: ["9983", "9984", "9983"],
|
||||||
|
taxablePaise: 1_000_000,
|
||||||
|
cgstPaise: 90_025,
|
||||||
|
sgstPaise: 90_025,
|
||||||
|
igstPaise: 0,
|
||||||
|
reverseCharge: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnIndex = (key: string) => HISTORY_COLUMNS.findIndex((c) => c.key === key);
|
||||||
|
|
||||||
|
describe("paiseToDecimal", () => {
|
||||||
|
it("formats from integer paise without float math", () => {
|
||||||
|
expect(paiseToDecimal(0)).toBe("0.00");
|
||||||
|
expect(paiseToDecimal(5)).toBe("0.05");
|
||||||
|
expect(paiseToDecimal(100)).toBe("1.00");
|
||||||
|
expect(paiseToDecimal(123_450)).toBe("1234.50");
|
||||||
|
expect(paiseToDecimal(-5)).toBe("-0.05");
|
||||||
|
expect(paiseToDecimal(-123_456)).toBe("-1234.56");
|
||||||
|
// 1.005 * 100 style float traps cannot happen: 100_100_000_000_005 paise.
|
||||||
|
expect(paiseToDecimal(100_100_000_000_005)).toBe("1001000000000.05");
|
||||||
|
});
|
||||||
|
it("rejects non-integers", () => {
|
||||||
|
expect(() => paiseToDecimal(1.5)).toThrow();
|
||||||
|
expect(() => paiseToDecimal(Number.NaN)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rows", () => {
|
||||||
|
it("builds the CA-friendly columns", () => {
|
||||||
|
const [row] = buildHistoryRows([summary()], [ledger()]);
|
||||||
|
const get = (key: string) => row[columnIndex(key)];
|
||||||
|
expect(HISTORY_COLUMNS.map((c) => c.header)).toEqual([
|
||||||
|
"Invoice No", "Date", "Due Date", "Client", "Client GSTIN", "Place of Supply Code", "Place of Supply",
|
||||||
|
"Document Type", "HSN/SAC", "Taxable Value", "CGST", "SGST", "IGST", "Total Tax", "Invoice Total",
|
||||||
|
"Received", "TDS", "Balance", "Status", "Payment Status", "PO Number", "Cancelled",
|
||||||
|
]);
|
||||||
|
expect(get("invoiceNo")).toBe("INV/2025-001");
|
||||||
|
expect(get("placeOfSupplyCode")).toBe("29");
|
||||||
|
expect(get("placeOfSupply")).toBe("Karnataka");
|
||||||
|
expect(get("documentType")).toBe("Tax Invoice");
|
||||||
|
expect(get("hsnSac")).toBe("9983; 9984");
|
||||||
|
expect(get("taxableValue")).toBe("10000.00");
|
||||||
|
expect(get("cgst")).toBe("900.25");
|
||||||
|
expect(get("igst")).toBe("0.00");
|
||||||
|
expect(get("totalTax")).toBe("1800.50");
|
||||||
|
expect(get("invoiceTotal")).toBe("11800.50");
|
||||||
|
expect(get("received")).toBe("10000.00");
|
||||||
|
expect(get("tds")).toBe("500.00");
|
||||||
|
expect(get("balance")).toBe("1300.50");
|
||||||
|
expect(get("status")).toBe("Issued");
|
||||||
|
expect(get("paymentStatus")).toBe("Partly paid");
|
||||||
|
expect(get("cancelled")).toBe("No");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags cancelled invoices and leaves ledger fields blank when the row is missing", () => {
|
||||||
|
const [row] = buildHistoryRows([summary({ status: "cancelled", paymentStatus: "none" })], []);
|
||||||
|
expect(row[columnIndex("cancelled")]).toBe("Yes");
|
||||||
|
expect(row[columnIndex("status")]).toBe("Cancelled");
|
||||||
|
expect(row[columnIndex("paymentStatus")]).toBe("");
|
||||||
|
expect(row[columnIndex("taxableValue")]).toBe("");
|
||||||
|
expect(row[columnIndex("invoiceTotal")]).toBe("11800.50");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSV", () => {
|
||||||
|
it("quotes per RFC 4180", () => {
|
||||||
|
expect(csvField("plain")).toBe("plain");
|
||||||
|
expect(csvField("a,b")).toBe('"a,b"');
|
||||||
|
expect(csvField('say "hi"')).toBe('"say ""hi"""');
|
||||||
|
expect(csvField("two\nlines")).toBe('"two\nlines"');
|
||||||
|
expect(csvField("cr\rhere")).toBe('"cr\rhere"');
|
||||||
|
expect(csvField(" padded")).toBe('" padded"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards text cells against formula injection but leaves amounts numeric", () => {
|
||||||
|
for (const bad of ["=1+1", "+SUM(A1)", "-2+3", "@cmd", "\tTAB", "\rCR"]) {
|
||||||
|
expect(guardFormula(bad)).toBe(`'${bad}`);
|
||||||
|
}
|
||||||
|
expect(guardFormula("INV/2025-001")).toBe("INV/2025-001");
|
||||||
|
const cells = buildHistoryRows(
|
||||||
|
[summary({ clientName: "=HYPERLINK(\"http://x\")", poNumber: "-5", balancePaise: -2_500, paidPaise: 0 })],
|
||||||
|
[ledger()],
|
||||||
|
);
|
||||||
|
const csv = toCsv(cells);
|
||||||
|
const dataLine = csv.split("\r\n")[1];
|
||||||
|
expect(dataLine).toContain(`"'=HYPERLINK(""http://x"")"`);
|
||||||
|
expect(dataLine).toContain(",'-5,"); // PO number text is guarded
|
||||||
|
expect(dataLine).toContain(",-25.00,"); // the numeric balance is not
|
||||||
|
expect(dataLine).not.toContain("'-25.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has a BOM, CRLF line ends and a trailing newline", () => {
|
||||||
|
const csv = toCsv(buildHistoryRows([summary(), summary({ id: 2, number: "INV/2025-002" })], [ledger(), ledger({ id: 2 })]));
|
||||||
|
expect(csv.startsWith("Invoice No,Date,")).toBe(true);
|
||||||
|
expect(csv.endsWith("\r\n")).toBe(true);
|
||||||
|
expect(csv.split("\r\n")).toHaveLength(4); // header, two rows, empty after the last CRLF
|
||||||
|
expect(csv.replace(/\r\n/g, "").includes("\n")).toBe(false);
|
||||||
|
expect(new TextEncoder().encode(csv).slice(0, 3)).toEqual(new Uint8Array([0xef, 0xbb, 0xbf]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty set is a header-only file", () => {
|
||||||
|
const csv = toCsv(buildHistoryRows([], []));
|
||||||
|
expect(csv.split("\r\n")).toEqual([expect.stringMatching(/^Invoice No,.*Cancelled$/), ""]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JSON", () => {
|
||||||
|
it("wraps the rows with schema, time and filters", () => {
|
||||||
|
const filters = { ...NO_FILTERS, fy: 2025 as const, search: "acme" };
|
||||||
|
const parsed = JSON.parse(toJson(buildHistoryRows([summary()], [ledger()]), filters, "2026-01-02T03:04:05.000Z"));
|
||||||
|
expect(parsed.schema).toBe("voiced.history.v1");
|
||||||
|
expect(parsed.exported_at).toBe("2026-01-02T03:04:05.000Z");
|
||||||
|
expect(parsed.filters).toEqual(filters);
|
||||||
|
expect(parsed.count).toBe(1);
|
||||||
|
expect(parsed.rows[0]).toMatchObject({ invoiceNo: "INV/2025-001", taxableValue: "10000.00", totalTax: "1800.50", cancelled: "No" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty set has no rows", () => {
|
||||||
|
const parsed = JSON.parse(toJson([], NO_FILTERS, "t"));
|
||||||
|
expect(parsed.rows).toEqual([]);
|
||||||
|
expect(parsed.count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("historyExportName", () => {
|
||||||
|
it("uses the financial year when one is picked", () => {
|
||||||
|
expect(historyExportName({ ...NO_FILTERS, fy: 2025 }, [], "csv")).toBe("voiced-history-FY2025-26.csv");
|
||||||
|
expect(historyExportName({ ...NO_FILTERS, fy: 2025, month: 4 }, [], "json")).toBe("voiced-history-FY2025-26-m04.json");
|
||||||
|
});
|
||||||
|
it("otherwise the date range of the rows", () => {
|
||||||
|
const rows = [summary({ invoiceDate: "2025-06-10" }), summary({ invoiceDate: "2025-04-02" })];
|
||||||
|
expect(historyExportName(NO_FILTERS, rows, "csv")).toBe("voiced-history-2025-04-02_to_2025-06-10.csv");
|
||||||
|
expect(historyExportName(NO_FILTERS, [rows[0]], "csv")).toBe("voiced-history-2025-06-10.csv");
|
||||||
|
expect(historyExportName(NO_FILTERS, [], "csv")).toBe("voiced-history-all.csv");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportHistory", () => {
|
||||||
|
const make = (chosen: string | null) => {
|
||||||
|
const written: { path: string; text: string }[] = [];
|
||||||
|
const saves: { defaultPath: string; format: string }[] = [];
|
||||||
|
const deps: HistoryExportDeps = {
|
||||||
|
listLedger: async () => [ledger()],
|
||||||
|
getLastExportDir: async () => "/home/a/Exports",
|
||||||
|
save: async (o) => {
|
||||||
|
saves.push(o);
|
||||||
|
return chosen;
|
||||||
|
},
|
||||||
|
writeExportFile: async (path, bytes) => {
|
||||||
|
written.push({ path, text: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes) });
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
now: () => new Date("2026-01-02T03:04:05Z"),
|
||||||
|
};
|
||||||
|
return { deps, written, saves };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("writes exactly the rows given, through the save dialog", async () => {
|
||||||
|
const { deps, written, saves } = make("/home/a/Exports/out.csv");
|
||||||
|
const out = await exportHistory(deps, [summary()], { ...NO_FILTERS, fy: 2025 }, "csv");
|
||||||
|
expect(out).toEqual({ path: "/home/a/Exports/out.csv", count: 1 });
|
||||||
|
expect(saves[0]).toEqual({ defaultPath: "/home/a/Exports/voiced-history-FY2025-26.csv", format: "csv" });
|
||||||
|
expect(written).toHaveLength(1);
|
||||||
|
expect(written[0].text.startsWith("Invoice No")).toBe(true);
|
||||||
|
expect(written[0].text.split("\r\n")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing when the dialog is cancelled, and a header-only file for an empty set", async () => {
|
||||||
|
const cancelled = make(null);
|
||||||
|
expect(await exportHistory(cancelled.deps, [summary()], NO_FILTERS, "json")).toBeNull();
|
||||||
|
expect(cancelled.written).toEqual([]);
|
||||||
|
const empty = make("/x/e.json");
|
||||||
|
expect(await exportHistory(empty.deps, [], NO_FILTERS, "json")).toEqual({ path: "/x/e.json", count: 0 });
|
||||||
|
expect(JSON.parse(empty.written[0].text).rows).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { fyLabel } from "./fiscal";
|
||||||
|
import type { HistoryFilters } from "./historyFilter";
|
||||||
|
import { INDIAN_STATES, type InvoiceSummary } from "./types";
|
||||||
|
|
||||||
|
/** Per-invoice fields the History list does not carry (from the `list_invoice_ledger` command). Money is integer paise. */
|
||||||
|
export interface LedgerRow {
|
||||||
|
id: number;
|
||||||
|
clientGstin: string;
|
||||||
|
placeOfSupplyCode: string;
|
||||||
|
docType: string;
|
||||||
|
hsnSac: string[];
|
||||||
|
taxablePaise: number;
|
||||||
|
cgstPaise: number;
|
||||||
|
sgstPaise: number;
|
||||||
|
igstPaise: number;
|
||||||
|
reverseCharge: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HISTORY_SCHEMA = "voiced.history.v1";
|
||||||
|
|
||||||
|
/** 123450 -> "1234.50", -5 -> "-0.05". Integer arithmetic only; throws on anything that is not a safe integer. */
|
||||||
|
export function paiseToDecimal(paise: number): string {
|
||||||
|
if (!Number.isSafeInteger(paise)) throw new Error(`Not an amount in paise: ${paise}`);
|
||||||
|
const abs = Math.abs(paise);
|
||||||
|
const whole = Math.floor(abs / 100);
|
||||||
|
const frac = abs % 100;
|
||||||
|
return `${paise < 0 ? "-" : ""}${whole}.${frac < 10 ? "0" : ""}${frac}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text cells are guarded against spreadsheet formula injection; amount cells are known numbers and stay numeric. */
|
||||||
|
type Kind = "text" | "amount";
|
||||||
|
|
||||||
|
interface Column {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
kind: Kind;
|
||||||
|
value: (s: InvoiceSummary, l: LedgerRow | undefined) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_TYPES: Record<string, string> = { tax_invoice: "Tax Invoice", invoice: "Invoice" };
|
||||||
|
const PAYMENT_STATUS: Record<string, string> = {
|
||||||
|
unpaid: "Unpaid",
|
||||||
|
partially_paid: "Partly paid",
|
||||||
|
paid: "Paid",
|
||||||
|
overdue: "Overdue",
|
||||||
|
none: "",
|
||||||
|
};
|
||||||
|
const STATUS: Record<string, string> = { issued: "Issued", cancelled: "Cancelled", draft: "Draft" };
|
||||||
|
|
||||||
|
/** An amount that comes from the ledger row; blank (not a made-up zero) if the row is missing. */
|
||||||
|
const ledgerAmount = (pick: (l: LedgerRow) => number) => (_s: InvoiceSummary, l: LedgerRow | undefined) =>
|
||||||
|
l ? paiseToDecimal(pick(l)) : "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column order and meaning for both formats. Cancelled invoices keep their figures (the document exists and
|
||||||
|
* keeps its number); filter on the Cancelled column to leave them out of a sum.
|
||||||
|
*/
|
||||||
|
export const HISTORY_COLUMNS: readonly Column[] = [
|
||||||
|
{ key: "invoiceNo", header: "Invoice No", kind: "text", value: (s) => s.number },
|
||||||
|
{ key: "date", header: "Date", kind: "text", value: (s) => s.invoiceDate },
|
||||||
|
{ key: "dueDate", header: "Due Date", kind: "text", value: (s) => s.dueDate },
|
||||||
|
{ key: "client", header: "Client", kind: "text", value: (s) => s.clientName },
|
||||||
|
{ key: "clientGstin", header: "Client GSTIN", kind: "text", value: (_s, l) => l?.clientGstin ?? "" },
|
||||||
|
{ key: "placeOfSupplyCode", header: "Place of Supply Code", kind: "text", value: (_s, l) => l?.placeOfSupplyCode ?? "" },
|
||||||
|
{
|
||||||
|
key: "placeOfSupply",
|
||||||
|
header: "Place of Supply",
|
||||||
|
kind: "text",
|
||||||
|
value: (_s, l) => INDIAN_STATES.find((st) => st.code === l?.placeOfSupplyCode)?.name ?? "",
|
||||||
|
},
|
||||||
|
{ key: "documentType", header: "Document Type", kind: "text", value: (_s, l) => (l ? (DOC_TYPES[l.docType] ?? l.docType) : "") },
|
||||||
|
{ key: "hsnSac", header: "HSN/SAC", kind: "text", value: (_s, l) => (l ? [...new Set(l.hsnSac)].join("; ") : "") },
|
||||||
|
{ key: "taxableValue", header: "Taxable Value", kind: "amount", value: ledgerAmount((l) => l.taxablePaise) },
|
||||||
|
{ key: "cgst", header: "CGST", kind: "amount", value: ledgerAmount((l) => l.cgstPaise) },
|
||||||
|
{ key: "sgst", header: "SGST", kind: "amount", value: ledgerAmount((l) => l.sgstPaise) },
|
||||||
|
{ key: "igst", header: "IGST", kind: "amount", value: ledgerAmount((l) => l.igstPaise) },
|
||||||
|
{ key: "totalTax", header: "Total Tax", kind: "amount", value: ledgerAmount((l) => l.cgstPaise + l.sgstPaise + l.igstPaise) },
|
||||||
|
{ key: "invoiceTotal", header: "Invoice Total", kind: "amount", value: (s) => paiseToDecimal(s.totalPaise) },
|
||||||
|
{ key: "received", header: "Received", kind: "amount", value: (s) => paiseToDecimal(s.paidPaise) },
|
||||||
|
{ key: "tds", header: "TDS", kind: "amount", value: (s) => paiseToDecimal(s.tdsPaise) },
|
||||||
|
{ key: "balance", header: "Balance", kind: "amount", value: (s) => paiseToDecimal(s.balancePaise) },
|
||||||
|
{ key: "status", header: "Status", kind: "text", value: (s) => STATUS[s.status] ?? s.status },
|
||||||
|
{ key: "paymentStatus", header: "Payment Status", kind: "text", value: (s) => PAYMENT_STATUS[s.paymentStatus] ?? s.paymentStatus },
|
||||||
|
{ key: "poNumber", header: "PO Number", kind: "text", value: (s) => s.poNumber },
|
||||||
|
{ key: "cancelled", header: "Cancelled", kind: "text", value: (s) => (s.status === "cancelled" ? "Yes" : "No") },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The cells of every exported row, in column order. `ledger` is looked up by invoice id. */
|
||||||
|
export function buildHistoryRows(summaries: InvoiceSummary[], ledger: LedgerRow[]): string[][] {
|
||||||
|
const byId = new Map(ledger.map((l) => [l.id, l]));
|
||||||
|
return summaries.map((s) => HISTORY_COLUMNS.map((c) => c.value(s, byId.get(s.id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A text cell that a spreadsheet would read as a formula gets a leading apostrophe. */
|
||||||
|
export function guardFormula(text: string): string {
|
||||||
|
return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 4180 field: quoted when it holds a quote, comma or line break (or edge spaces), inner quotes doubled. */
|
||||||
|
export function csvField(text: string): string {
|
||||||
|
return /[",\r\n]|^\s|\s$/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTF-8 BOM (so Excel reads it as UTF-8), CRLF line ends, header row always present. */
|
||||||
|
export function toCsv(cells: string[][]): string {
|
||||||
|
const lines = [HISTORY_COLUMNS.map((c) => csvField(c.header))];
|
||||||
|
for (const row of cells) {
|
||||||
|
lines.push(
|
||||||
|
row.map((value, i) => {
|
||||||
|
const column = HISTORY_COLUMNS[i];
|
||||||
|
return csvField(column.kind === "text" ? guardFormula(value) : value);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${lines.map((l) => l.join(",")).join("\r\n")}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{ schema, exported_at, filters, count, rows }`. Rows are objects keyed like the CSV columns; amounts are the same
|
||||||
|
* 2-decimal rupee strings as in the CSV (no binary floats anywhere).
|
||||||
|
*/
|
||||||
|
export function toJson(cells: string[][], filters: HistoryFilters, exportedAt: string): string {
|
||||||
|
const rows = cells.map((row) => Object.fromEntries(HISTORY_COLUMNS.map((c, i) => [c.key, row[i]])));
|
||||||
|
return `${JSON.stringify({ schema: HISTORY_SCHEMA, exported_at: exportedAt, filters, count: rows.length, rows }, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-");
|
||||||
|
|
||||||
|
/** `voiced-history-FY2025-26.csv`, or the date range of the rows when no year is picked, or `all` when empty. */
|
||||||
|
export function historyExportName(
|
||||||
|
filters: HistoryFilters,
|
||||||
|
summaries: InvoiceSummary[],
|
||||||
|
ext: "csv" | "json",
|
||||||
|
): string {
|
||||||
|
let part: string;
|
||||||
|
if (filters.fy !== "all") {
|
||||||
|
part = fyLabel(filters.fy).replace(/^FY /, "FY");
|
||||||
|
if (filters.month !== "all") part += `-m${String(filters.month).padStart(2, "0")}`;
|
||||||
|
} else {
|
||||||
|
const dates = summaries.map((s) => s.invoiceDate).filter(Boolean).sort();
|
||||||
|
if (dates.length === 0) part = "all";
|
||||||
|
else part = dates[0] === dates[dates.length - 1] ? dates[0] : `${dates[0]}_to_${dates[dates.length - 1]}`;
|
||||||
|
}
|
||||||
|
return `voiced-history-${safeName(part)}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryFormat = "csv" | "json";
|
||||||
|
|
||||||
|
export interface HistoryExportDeps {
|
||||||
|
listLedger(): Promise<LedgerRow[]>;
|
||||||
|
getLastExportDir(): Promise<string>;
|
||||||
|
save(opts: { defaultPath: string; format: HistoryFormat }): Promise<string | null>;
|
||||||
|
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
|
||||||
|
now(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinPath(dir: string, name: string): string {
|
||||||
|
if (!dir) return name;
|
||||||
|
if (/[\\/]$/.test(dir)) return `${dir}${name}`;
|
||||||
|
return `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exports exactly `summaries` (the filtered, sorted rows) through the save dialog. Returns the path, or null if cancelled. */
|
||||||
|
export async function exportHistory(
|
||||||
|
deps: HistoryExportDeps,
|
||||||
|
summaries: InvoiceSummary[],
|
||||||
|
filters: HistoryFilters,
|
||||||
|
format: HistoryFormat,
|
||||||
|
): Promise<{ path: string; count: number } | null> {
|
||||||
|
const ledger = await deps.listLedger();
|
||||||
|
const cells = buildHistoryRows(summaries, ledger);
|
||||||
|
const text = format === "csv" ? toCsv(cells) : toJson(cells, filters, deps.now().toISOString());
|
||||||
|
let dir = "";
|
||||||
|
try {
|
||||||
|
dir = await deps.getLastExportDir();
|
||||||
|
} catch {
|
||||||
|
// No remembered folder: the dialog opens wherever the system prefers.
|
||||||
|
}
|
||||||
|
const chosen = await deps.save({ defaultPath: joinPath(dir, historyExportName(filters, summaries, format)), format });
|
||||||
|
if (!chosen) return null;
|
||||||
|
const path = (await deps.writeExportFile(chosen, new TextEncoder().encode(text))) || chosen;
|
||||||
|
return { path, count: cells.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { clientOptions, filterInvoices, fyOptions, fySummary, NO_FILTERS, sortInvoices, totalsOf } from "./historyFilter";
|
||||||
|
import type { InvoiceSummary } from "./types";
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
function row(over: Partial<InvoiceSummary>): InvoiceSummary {
|
||||||
|
const base: InvoiceSummary = {
|
||||||
|
id: nextId++,
|
||||||
|
number: "INV/2025-001",
|
||||||
|
invoiceDate: "2025-06-10",
|
||||||
|
dueDate: "2025-07-10",
|
||||||
|
clientName: "Acme",
|
||||||
|
total: 0,
|
||||||
|
status: "issued",
|
||||||
|
createdAt: "",
|
||||||
|
poNumber: "",
|
||||||
|
totalPaise: 100_000,
|
||||||
|
paidPaise: 0,
|
||||||
|
tdsPaise: 0,
|
||||||
|
balancePaise: 100_000,
|
||||||
|
paymentStatus: "unpaid",
|
||||||
|
};
|
||||||
|
return { ...base, ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: InvoiceSummary[] = [
|
||||||
|
row({ number: "INV/2025-001", invoiceDate: "2025-04-01", clientName: "Acme", poNumber: "PO-77", paymentStatus: "paid", paidPaise: 90_000, tdsPaise: 10_000, balancePaise: 0 }),
|
||||||
|
row({ number: "INV/2025-002", invoiceDate: "2025-05-20", clientName: "Beta Labs", totalPaise: 250_050, balancePaise: 250_050, paymentStatus: "overdue" }),
|
||||||
|
row({ number: "INV/2025-010", invoiceDate: "2026-03-31", clientName: "Acme", paidPaise: 30_000, balancePaise: 70_000, paymentStatus: "partially_paid" }),
|
||||||
|
row({ number: "INV/2026-001", invoiceDate: "2026-04-02", clientName: "Gamma", status: "cancelled", balancePaise: 0, paymentStatus: "none" }),
|
||||||
|
row({ number: "INV/2026-002", invoiceDate: "2026-04-05", clientName: "Acme", paymentStatus: "unpaid" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const f = (over: object) => filterInvoices(rows, { ...NO_FILTERS, ...over }).map((r) => r.number);
|
||||||
|
|
||||||
|
describe("filterInvoices", () => {
|
||||||
|
it("returns everything with no filters", () => expect(f({})).toHaveLength(5));
|
||||||
|
|
||||||
|
it("searches number, client and PO case-insensitively", () => {
|
||||||
|
expect(f({ search: "beta" })).toEqual(["INV/2025-002"]);
|
||||||
|
expect(f({ search: "po-77" })).toEqual(["INV/2025-001"]);
|
||||||
|
expect(f({ search: " 2026-00 " })).toEqual(["INV/2026-001", "INV/2026-002"]);
|
||||||
|
expect(f({ search: "nothing" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by status and payment status", () => {
|
||||||
|
expect(f({ status: "cancelled" })).toEqual(["INV/2026-001"]);
|
||||||
|
expect(f({ status: "issued" })).toHaveLength(4);
|
||||||
|
expect(f({ status: "paid" })).toEqual(["INV/2025-001"]);
|
||||||
|
expect(f({ status: "overdue" })).toEqual(["INV/2025-002"]);
|
||||||
|
expect(f({ status: "partially_paid" })).toEqual(["INV/2025-010"]);
|
||||||
|
// An overdue invoice with nothing received is also unpaid.
|
||||||
|
expect(f({ status: "unpaid" })).toEqual(["INV/2025-002", "INV/2026-002"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by financial year, month and client", () => {
|
||||||
|
expect(f({ fy: 2025 })).toEqual(["INV/2025-001", "INV/2025-002", "INV/2025-010"]);
|
||||||
|
expect(f({ fy: 2026 })).toEqual(["INV/2026-001", "INV/2026-002"]);
|
||||||
|
expect(f({ month: 4 })).toEqual(["INV/2025-001", "INV/2026-001", "INV/2026-002"]);
|
||||||
|
expect(f({ fy: 2026, month: 4 })).toEqual(["INV/2026-001", "INV/2026-002"]);
|
||||||
|
expect(f({ client: "Acme", fy: 2025 })).toEqual(["INV/2025-001", "INV/2025-010"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortInvoices", () => {
|
||||||
|
const nums = (sort: Parameters<typeof sortInvoices>[1]) => sortInvoices(rows, sort).map((r) => r.number);
|
||||||
|
|
||||||
|
it("sorts numbers naturally, so 010 follows 002", () => {
|
||||||
|
expect(nums({ key: "number", dir: "asc" }).slice(0, 3)).toEqual(["INV/2025-001", "INV/2025-002", "INV/2025-010"]);
|
||||||
|
expect(nums({ key: "number", dir: "desc" })[0]).toBe("INV/2026-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts by date, total and balance", () => {
|
||||||
|
expect(nums({ key: "date", dir: "desc" })[0]).toBe("INV/2026-002");
|
||||||
|
expect(nums({ key: "total", dir: "desc" })[0]).toBe("INV/2025-002");
|
||||||
|
expect(nums({ key: "balance", dir: "asc" }).slice(0, 2).sort()).toEqual(["INV/2025-001", "INV/2026-001"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts by status with overdue first and cancelled last, and does not mutate", () => {
|
||||||
|
const before = rows.map((r) => r.id);
|
||||||
|
const s = sortInvoices(rows, { key: "status", dir: "asc" });
|
||||||
|
expect(s[0].paymentStatus).toBe("overdue");
|
||||||
|
expect(s[s.length - 1].status).toBe("cancelled");
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks ties by newest id", () => {
|
||||||
|
const tied = [row({ clientName: "Same" }), row({ clientName: "Same" })];
|
||||||
|
expect(sortInvoices(tied, { key: "client", dir: "asc" })[0].id).toBe(tied[1].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("totals", () => {
|
||||||
|
it("adds paise exactly and ignores cancelled invoices", () => {
|
||||||
|
const t = totalsOf(rows);
|
||||||
|
expect(t.count).toBe(5);
|
||||||
|
expect(t.invoicedPaise).toBe(100_000 + 250_050 + 100_000 + 100_000);
|
||||||
|
expect(t.receivedPaise).toBe(120_000);
|
||||||
|
expect(t.tdsPaise).toBe(10_000);
|
||||||
|
expect(t.outstandingPaise).toBe(250_050 + 70_000 + 100_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps cents exact where floats would drift", () => {
|
||||||
|
const many = Array.from({ length: 1000 }, () => row({ totalPaise: 10, balancePaise: 10 }));
|
||||||
|
expect(totalsOf(many).invoicedPaise).toBe(10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarises one financial year including overdue", () => {
|
||||||
|
const s = fySummary(rows, 2025);
|
||||||
|
expect(s.count).toBe(3);
|
||||||
|
expect(s.overdueCount).toBe(1);
|
||||||
|
expect(s.overduePaise).toBe(250_050);
|
||||||
|
expect(fySummary(rows, 2030).invoicedPaise).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("options", () => {
|
||||||
|
it("lists financial years newest first and always the current one", () => {
|
||||||
|
expect(fyOptions(rows, 2025)).toEqual([2026, 2025]);
|
||||||
|
expect(fyOptions([], 2027)).toEqual([2027]);
|
||||||
|
});
|
||||||
|
it("lists distinct clients", () => expect(clientOptions(rows)).toEqual(["Acme", "Beta Labs", "Gamma"]));
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { fyStartYear, monthOf } from "./fiscal";
|
||||||
|
import type { InvoiceSummary } from "./types";
|
||||||
|
|
||||||
|
export type StatusFilter = "all" | "issued" | "cancelled" | "unpaid" | "partially_paid" | "paid" | "overdue";
|
||||||
|
|
||||||
|
export const STATUS_FILTERS: { id: StatusFilter; label: string }[] = [
|
||||||
|
{ id: "all", label: "All statuses" },
|
||||||
|
{ id: "issued", label: "Issued" },
|
||||||
|
{ id: "unpaid", label: "Unpaid" },
|
||||||
|
{ id: "partially_paid", label: "Partly paid" },
|
||||||
|
{ id: "paid", label: "Paid" },
|
||||||
|
{ id: "overdue", label: "Overdue" },
|
||||||
|
{ id: "cancelled", label: "Cancelled" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface HistoryFilters {
|
||||||
|
search: string;
|
||||||
|
status: StatusFilter;
|
||||||
|
/** Financial-year start year, or "all". */
|
||||||
|
fy: number | "all";
|
||||||
|
/** Calendar month 1-12, or "all". */
|
||||||
|
month: number | "all";
|
||||||
|
client: string | "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NO_FILTERS: HistoryFilters = { search: "", status: "all", fy: "all", month: "all", client: "all" };
|
||||||
|
|
||||||
|
export type SortKey = "number" | "date" | "client" | "total" | "balance" | "status";
|
||||||
|
export interface SortSpec {
|
||||||
|
key: SortKey;
|
||||||
|
dir: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All amounts are integer paise. Cancelled invoices count for nothing. */
|
||||||
|
export interface Totals {
|
||||||
|
count: number;
|
||||||
|
invoicedPaise: number;
|
||||||
|
receivedPaise: number;
|
||||||
|
tdsPaise: number;
|
||||||
|
outstandingPaise: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settled = (r: InvoiceSummary) => r.paidPaise + r.tdsPaise;
|
||||||
|
|
||||||
|
function matchesStatus(r: InvoiceSummary, f: StatusFilter): boolean {
|
||||||
|
switch (f) {
|
||||||
|
case "all":
|
||||||
|
return true;
|
||||||
|
case "issued":
|
||||||
|
return r.status === "issued";
|
||||||
|
case "cancelled":
|
||||||
|
return r.status === "cancelled";
|
||||||
|
case "unpaid":
|
||||||
|
return r.status === "issued" && settled(r) === 0;
|
||||||
|
case "partially_paid":
|
||||||
|
return r.status === "issued" && settled(r) > 0 && r.balancePaise > 0;
|
||||||
|
case "paid":
|
||||||
|
return r.status === "issued" && r.paymentStatus === "paid";
|
||||||
|
case "overdue":
|
||||||
|
return r.status === "issued" && r.paymentStatus === "overdue";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows that satisfy every filter. The search is a case-insensitive substring of number, client or PO number. */
|
||||||
|
export function filterInvoices(rows: InvoiceSummary[], f: HistoryFilters): InvoiceSummary[] {
|
||||||
|
const q = f.search.trim().toLowerCase();
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (q && !`${r.number}\n${r.clientName}\n${r.poNumber}`.toLowerCase().includes(q)) return false;
|
||||||
|
if (!matchesStatus(r, f.status)) return false;
|
||||||
|
if (f.fy !== "all" && fyStartYear(r.invoiceDate) !== f.fy) return false;
|
||||||
|
if (f.month !== "all" && monthOf(r.invoiceDate) !== f.month) return false;
|
||||||
|
if (f.client !== "all" && r.clientName !== f.client) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_RANK: Record<string, number> = { overdue: 0, unpaid: 1, partially_paid: 2, paid: 3, none: 4 };
|
||||||
|
const statusRank = (r: InvoiceSummary) => (r.status === "cancelled" ? 5 : (STATUS_RANK[r.paymentStatus] ?? 4));
|
||||||
|
const numeric = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
|
||||||
|
|
||||||
|
function compare(a: InvoiceSummary, b: InvoiceSummary, key: SortKey): number {
|
||||||
|
switch (key) {
|
||||||
|
case "number":
|
||||||
|
return numeric.compare(a.number, b.number);
|
||||||
|
case "date":
|
||||||
|
return a.invoiceDate < b.invoiceDate ? -1 : a.invoiceDate > b.invoiceDate ? 1 : 0;
|
||||||
|
case "client":
|
||||||
|
return numeric.compare(a.clientName, b.clientName);
|
||||||
|
case "total":
|
||||||
|
return a.totalPaise - b.totalPaise;
|
||||||
|
case "balance":
|
||||||
|
return a.balancePaise - b.balancePaise;
|
||||||
|
case "status":
|
||||||
|
return statusRank(a) - statusRank(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new, stably sorted array; ties fall back to the newest invoice first so the order never jitters. */
|
||||||
|
export function sortInvoices(rows: InvoiceSummary[], sort: SortSpec): InvoiceSummary[] {
|
||||||
|
const sign = sort.dir === "asc" ? 1 : -1;
|
||||||
|
return [...rows].sort((a, b) => sign * compare(a, b, sort.key) || b.id - a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalsOf(rows: InvoiceSummary[]): Totals {
|
||||||
|
const t: Totals = { count: rows.length, invoicedPaise: 0, receivedPaise: 0, tdsPaise: 0, outstandingPaise: 0 };
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.status === "cancelled") continue;
|
||||||
|
t.invoicedPaise += r.totalPaise;
|
||||||
|
t.receivedPaise += r.paidPaise;
|
||||||
|
t.tdsPaise += r.tdsPaise;
|
||||||
|
t.outstandingPaise += r.balancePaise;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FySummary extends Totals {
|
||||||
|
overdueCount: number;
|
||||||
|
overduePaise: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tiles above the table: everything dated in one financial year, regardless of the other filters. */
|
||||||
|
export function fySummary(rows: InvoiceSummary[], fy: number): FySummary {
|
||||||
|
const inYear = rows.filter((r) => fyStartYear(r.invoiceDate) === fy);
|
||||||
|
const overdue = inYear.filter((r) => r.status === "issued" && r.paymentStatus === "overdue");
|
||||||
|
return {
|
||||||
|
...totalsOf(inYear),
|
||||||
|
overdueCount: overdue.length,
|
||||||
|
overduePaise: overdue.reduce((sum, r) => sum + r.balancePaise, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Financial years that have invoices, newest first, always including `current`. */
|
||||||
|
export function fyOptions(rows: InvoiceSummary[], current: number): number[] {
|
||||||
|
const years = new Set<number>([current]);
|
||||||
|
for (const r of rows) {
|
||||||
|
const y = fyStartYear(r.invoiceDate);
|
||||||
|
if (y !== null) years.add(y);
|
||||||
|
}
|
||||||
|
return [...years].sort((a, b) => b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct client names, alphabetical, blanks left out. */
|
||||||
|
export function clientOptions(rows: InvoiceSummary[]): string[] {
|
||||||
|
return [...new Set(rows.map((r) => r.clientName.trim()).filter(Boolean))].sort((a, b) => numeric.compare(a, b));
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildInvoiceDoc,
|
||||||
|
exportInvoicesV1,
|
||||||
|
invoiceExportName,
|
||||||
|
LINE_CSV_HEADERS,
|
||||||
|
loadInvoiceDocs,
|
||||||
|
rateString,
|
||||||
|
rupeesToPaise,
|
||||||
|
toInvoiceJson,
|
||||||
|
toLineItemsCsv,
|
||||||
|
type InvoiceExportDeps,
|
||||||
|
} from "./invoiceExportV1";
|
||||||
|
import type { Payment } from "./payments";
|
||||||
|
import { blankClient, type Client, type Invoice } from "./types";
|
||||||
|
|
||||||
|
const VENDOR = (patch: Record<string, unknown> = {}) =>
|
||||||
|
JSON.stringify({
|
||||||
|
vendorName: "Test Vendor",
|
||||||
|
vendorAddress: "1 Main Rd\nPune",
|
||||||
|
vendorEmail: "a@example.com",
|
||||||
|
vendorPhone: "999",
|
||||||
|
vendorPan: "AAPFU0939F",
|
||||||
|
vendorGstin: "27AAPFU0939F1ZV",
|
||||||
|
vendorStateCode: "27",
|
||||||
|
logoPath: "/secret/logo.png",
|
||||||
|
signaturePath: "/secret/sig.png",
|
||||||
|
gstRegistration: "regular",
|
||||||
|
signatoryName: "Sample",
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoice = (patch: Partial<Invoice> = {}): Invoice => ({
|
||||||
|
id: 1,
|
||||||
|
number: "INV/2026-001",
|
||||||
|
seriesId: 1,
|
||||||
|
invoiceDate: "2026-04-01",
|
||||||
|
dueDate: "2026-05-01",
|
||||||
|
clientId: 5,
|
||||||
|
clientName: "Client Ltd",
|
||||||
|
clientAddress: "12 MG Road\nBengaluru 560001",
|
||||||
|
clientGstin: "29AABCU9603R1ZJ",
|
||||||
|
poNumber: "PO-9",
|
||||||
|
placeOfSupplyStateCode: "29",
|
||||||
|
subtotal: 7310.1,
|
||||||
|
discount: 10.1,
|
||||||
|
taxType: "igst",
|
||||||
|
taxRate: 18,
|
||||||
|
cgstAmount: 0,
|
||||||
|
sgstAmount: 0,
|
||||||
|
igstAmount: 1314,
|
||||||
|
total: 8614,
|
||||||
|
amountInWords: "Rupees Eight Thousand Six Hundred Fourteen Only",
|
||||||
|
bankAccountId: null,
|
||||||
|
bankSnapshot: "",
|
||||||
|
signaturePath: null,
|
||||||
|
notes: "Thank you",
|
||||||
|
status: "issued",
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-04-01T00:00:00Z",
|
||||||
|
docType: "tax_invoice",
|
||||||
|
reverseCharge: false,
|
||||||
|
vendorSnapshot: VENDOR(),
|
||||||
|
snapshotOrigin: "issued",
|
||||||
|
cancelledAt: null,
|
||||||
|
cancelReason: "",
|
||||||
|
archivedPdfSha256: null,
|
||||||
|
renderPrefs: "",
|
||||||
|
items: [
|
||||||
|
{ id: 1, description: "Design", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 5000.1, sortOrder: 0, hsnSac: "998391" },
|
||||||
|
{ id: 2, description: "Consulting", mode: "rate", rate: 1155.05, unit: "hour", quantity: 2, amount: 2310, sortOrder: 1, hsnSac: "" },
|
||||||
|
],
|
||||||
|
totalPaise: 861400,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client: Client = {
|
||||||
|
...blankClient(),
|
||||||
|
id: 5,
|
||||||
|
name: "Client Ltd",
|
||||||
|
stateCode: "29",
|
||||||
|
addressLine1: "12 MG Road",
|
||||||
|
addressLine2: "",
|
||||||
|
city: "Bengaluru",
|
||||||
|
pincode: "560001",
|
||||||
|
gstCategory: "registered_regular",
|
||||||
|
};
|
||||||
|
|
||||||
|
const payment = (patch: Partial<Payment> = {}): Payment => ({
|
||||||
|
id: 1,
|
||||||
|
invoiceId: 1,
|
||||||
|
paidOn: "2026-04-10",
|
||||||
|
amountPaise: 700000,
|
||||||
|
tdsPaise: 70000,
|
||||||
|
mode: "bank_transfer",
|
||||||
|
reference: "UTR123",
|
||||||
|
notes: "n",
|
||||||
|
createdAt: "x",
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("voiced.invoice.v1 document", () => {
|
||||||
|
it("matches the golden file for a fixture invoice", () => {
|
||||||
|
const text = toInvoiceJson([buildInvoiceDoc(invoice(), [payment()], client)]);
|
||||||
|
const path = new URL("./__golden__/invoice-v1.golden.json", import.meta.url);
|
||||||
|
if (!existsSync(path)) writeFileSync(path, text);
|
||||||
|
expect(text).toBe(readFileSync(path, "utf8"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic and keeps the key order", () => {
|
||||||
|
const a = toInvoiceJson([buildInvoiceDoc(invoice(), [payment()], client)]);
|
||||||
|
const b = toInvoiceJson([buildInvoiceDoc(invoice(), [payment()], client)]);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
const doc = buildInvoiceDoc(invoice(), [], null);
|
||||||
|
expect(Object.keys(doc).slice(0, 4)).toEqual(["schema", "number", "issue_date", "due_date"]);
|
||||||
|
expect(doc.schema).toBe("voiced.invoice.v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries every amount as exact paise and a matching decimal string", () => {
|
||||||
|
const doc = buildInvoiceDoc(invoice(), [payment()], client);
|
||||||
|
// Plain float multiplication is off (0.29 * 100 is 28.999999999999996); the paise must be exact integers.
|
||||||
|
expect(0.29 * 100).not.toBe(29);
|
||||||
|
expect(doc.totals.subtotal_paise).toBe(731010);
|
||||||
|
expect(doc.totals.subtotal).toBe("7310.10");
|
||||||
|
expect(doc.totals.discount).toBe("10.10");
|
||||||
|
expect(doc.totals.taxable_value_paise).toBe(730000);
|
||||||
|
expect(doc.totals.tax).toBe("1314.00");
|
||||||
|
expect(doc.totals.total_paise).toBe(861400);
|
||||||
|
expect(doc.totals.total).toBe("8614.00");
|
||||||
|
expect(doc.items[0]).toMatchObject({ quantity: "1", unit: null, rate: "5000.10", taxable_value_paise: 500010, hsn_sac: "998391" });
|
||||||
|
expect(doc.items[1]).toMatchObject({ quantity: "2", unit: "hour", rate: "1155.05", rate_paise: 115505, hsn_sac: null });
|
||||||
|
expect(doc.payments[0]).toMatchObject({ amount: "7000.00", tds: "700.00", tds_paise: 70000 });
|
||||||
|
// Every *_paise field is a safe integer and agrees with its decimal.
|
||||||
|
const walk = (o: unknown, path: string[] = []) => {
|
||||||
|
if (!o || typeof o !== "object") return;
|
||||||
|
const rec = o as Record<string, unknown>;
|
||||||
|
for (const [k, v] of Object.entries(rec)) {
|
||||||
|
if (k.endsWith("_paise")) {
|
||||||
|
expect(Number.isSafeInteger(v), path.concat(k).join(".")).toBe(true);
|
||||||
|
const dec = rec[k.slice(0, -6)];
|
||||||
|
expect(dec, path.concat(k).join(".")).toBe(`${Math.trunc((v as number) / 100)}.${String(Math.abs(v as number) % 100).padStart(2, "0")}`);
|
||||||
|
} else walk(v, path.concat(k));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(JSON.parse(toInvoiceJson([doc])));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the tax heads: CGST+SGST at half rate, IGST whole, none for an unregistered vendor", () => {
|
||||||
|
const intra = buildInvoiceDoc(invoice({ taxType: "cgst_sgst", taxRate: 12.5, cgstAmount: 100.25, sgstAmount: 100.25, igstAmount: 0 }), [], client);
|
||||||
|
expect(intra.tax.cgst).toEqual({ rate: "6.25", amount_paise: 10025, amount: "100.25" });
|
||||||
|
expect(intra.tax.sgst).toEqual({ rate: "6.25", amount_paise: 10025, amount: "100.25" });
|
||||||
|
expect(intra.tax.igst).toEqual({ rate: "0", amount_paise: 0, amount: "0.00" });
|
||||||
|
expect(intra.tax.utgst.amount_paise).toBe(0);
|
||||||
|
expect(intra.totals.tax_paise).toBe(20050);
|
||||||
|
|
||||||
|
const inter = buildInvoiceDoc(invoice(), [], client);
|
||||||
|
expect(inter.tax.igst).toEqual({ rate: "18", amount_paise: 131400, amount: "1314.00" });
|
||||||
|
expect(inter.tax.cgst.rate).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the second head to UTGST when the supplier is in a union territory", () => {
|
||||||
|
const doc = buildInvoiceDoc(
|
||||||
|
invoice({ taxType: "cgst_sgst", taxRate: 18, cgstAmount: 90, sgstAmount: 90, igstAmount: 0, vendorSnapshot: VENDOR({ vendorStateCode: "04" }) }),
|
||||||
|
[],
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
expect(doc.tax.sgst.amount_paise).toBe(0);
|
||||||
|
expect(doc.tax.utgst).toEqual({ rate: "9", amount_paise: 9000, amount: "90.00" });
|
||||||
|
expect(doc.supplier.state).toEqual({ code: "04", name: "Chandigarh" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a cancelled invoice and keeps its figures", () => {
|
||||||
|
const doc = buildInvoiceDoc(invoice({ status: "cancelled", cancelledAt: "2026-04-05T10:00:00Z", cancelReason: "wrong client" }), [], client);
|
||||||
|
expect(doc).toMatchObject({ status: "cancelled", cancelled: true, cancelled_at: "2026-04-05T10:00:00Z", cancel_reason: "wrong client" });
|
||||||
|
expect(doc.totals.total).toBe("8614.00");
|
||||||
|
const live = buildInvoiceDoc(invoice(), [], client);
|
||||||
|
expect(live).toMatchObject({ cancelled: false, cancelled_at: null, cancel_reason: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports an unregistered vendor's plain invoice without tax or GSTIN", () => {
|
||||||
|
const doc = buildInvoiceDoc(
|
||||||
|
invoice({
|
||||||
|
docType: "invoice",
|
||||||
|
taxType: "none",
|
||||||
|
taxRate: 0,
|
||||||
|
igstAmount: 0,
|
||||||
|
subtotal: 7300,
|
||||||
|
discount: 0,
|
||||||
|
total: 7300,
|
||||||
|
totalPaise: 730000,
|
||||||
|
clientGstin: "",
|
||||||
|
vendorSnapshot: VENDOR({ gstRegistration: "unregistered", vendorGstin: "" }),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(doc.document_type).toBe("invoice");
|
||||||
|
expect(doc.supplier).toMatchObject({ gstin: null, gst_registration: "unregistered" });
|
||||||
|
expect(doc.totals.tax_paise).toBe(0);
|
||||||
|
expect(doc.tax.igst.rate).toBe("0");
|
||||||
|
expect(doc.client.gstin).toBeNull();
|
||||||
|
expect(doc.client.gst_category).toBe("unregistered");
|
||||||
|
expect(doc.client.address).toMatchObject({ line1: null, city: null, state: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak local file paths from the supplier snapshot", () => {
|
||||||
|
const text = toInvoiceJson([buildInvoiceDoc(invoice(), [], client)]);
|
||||||
|
expect(text).not.toContain("/secret/");
|
||||||
|
expect(text).not.toContain("a@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a missing or broken supplier snapshot", () => {
|
||||||
|
for (const vendorSnapshot of ["", "not json", "null"]) {
|
||||||
|
const doc = buildInvoiceDoc(invoice({ vendorSnapshot }), [], client);
|
||||||
|
expect(doc.supplier).toMatchObject({ name: "", gstin: null, state: { code: "", name: "" } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts rupees and rates without noise", () => {
|
||||||
|
expect(rupeesToPaise(0.29)).toBe(29);
|
||||||
|
expect(rupeesToPaise(-0.001)).toBe(0);
|
||||||
|
expect(() => rupeesToPaise(Number.NaN)).toThrow();
|
||||||
|
expect(rateString(9)).toBe("9");
|
||||||
|
expect(rateString(2.5)).toBe("2.5");
|
||||||
|
expect(rateString(0.1 + 0.2)).toBe("0.3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("line-items CSV", () => {
|
||||||
|
it("writes a BOM, CRLF, a header and one row per line with invoice amounts on the first row only", () => {
|
||||||
|
const csv = toLineItemsCsv([buildInvoiceDoc(invoice(), [], client)]);
|
||||||
|
expect(csv.startsWith("")).toBe(true);
|
||||||
|
const lines = csv.slice(1).split("\r\n");
|
||||||
|
expect(lines[0]).toBe(LINE_CSV_HEADERS.join(","));
|
||||||
|
expect(lines).toHaveLength(4); // header, 2 lines, trailing empty
|
||||||
|
const first = lines[1].split(",");
|
||||||
|
const second = lines[2].split(",");
|
||||||
|
const col = (name: string) => LINE_CSV_HEADERS.indexOf(name);
|
||||||
|
expect(first[col("invoice_total")]).toBe("8614.00");
|
||||||
|
expect(second[col("invoice_total")]).toBe("");
|
||||||
|
expect(first[col("invoice_igst")]).toBe("1314.00");
|
||||||
|
expect(second[col("taxable_value")]).toBe("2310.00");
|
||||||
|
expect(first[col("invoice_number")]).toBe("INV/2026-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotes commas and quotes, and guards formulas", () => {
|
||||||
|
const doc = buildInvoiceDoc(invoice({ items: [{ id: 1, description: '=SUM(A1), "x"', mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 10, sortOrder: 0 }] }), [], client);
|
||||||
|
const csv = toLineItemsCsv([doc]);
|
||||||
|
expect(csv).toContain(`"'=SUM(A1), ""x"""`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an invoice with no items as one row", () => {
|
||||||
|
const csv = toLineItemsCsv([buildInvoiceDoc(invoice({ items: [] }), [], client)]);
|
||||||
|
expect(csv.slice(1).split("\r\n")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saving", () => {
|
||||||
|
const deps = (over: Partial<InvoiceExportDeps> = {}): InvoiceExportDeps & { written: { path: string; text: string }[] } => {
|
||||||
|
const written: { path: string; text: string }[] = [];
|
||||||
|
return {
|
||||||
|
written,
|
||||||
|
getInvoice: async (id) => invoice({ id, number: `INV/2026-00${id}` }),
|
||||||
|
listPayments: async () => [payment()],
|
||||||
|
listClients: async () => [client],
|
||||||
|
getLastExportDir: async () => "/home/a/exports",
|
||||||
|
save: async ({ defaultPath }) => defaultPath,
|
||||||
|
writeExportFile: async (path, bytes) => {
|
||||||
|
written.push({ path, text: new TextDecoder().decode(bytes) });
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
now: () => new Date("2026-06-01T10:00:00Z"),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it("names the file after the invoice or the count", () => {
|
||||||
|
const one = [buildInvoiceDoc(invoice(), [], null)];
|
||||||
|
const now = new Date("2026-06-01T10:00:00Z");
|
||||||
|
expect(invoiceExportName(one, "json", now)).toBe("voiced-invoice-INV-2026-001.json");
|
||||||
|
expect(invoiceExportName(one, "csv", now)).toBe("voiced-invoice-INV-2026-001-lines.csv");
|
||||||
|
expect(invoiceExportName([...one, ...one], "json", now)).toBe("voiced-invoices-2-2026-06-01.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the chosen invoices in order, once each", async () => {
|
||||||
|
const d = deps();
|
||||||
|
const done = await exportInvoicesV1(d, [2, 1, 2], "json");
|
||||||
|
expect(done).toEqual({ path: "/home/a/exports/voiced-invoices-2-2026-06-01.json", count: 2 });
|
||||||
|
const docs = JSON.parse(d.written[0].text) as { number: string }[];
|
||||||
|
expect(docs.map((x) => x.number)).toEqual(["INV/2026-002", "INV/2026-001"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the dialog is cancelled and writes nothing", async () => {
|
||||||
|
const d = deps({ save: async () => null });
|
||||||
|
expect(await exportInvoicesV1(d, [1], "csv")).toBeNull();
|
||||||
|
expect(d.written).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still exports when the client list cannot be read", async () => {
|
||||||
|
const docs = await loadInvoiceDocs(deps({ listClients: async () => Promise.reject(new Error("db")) }), [1]);
|
||||||
|
expect(docs).toHaveLength(1);
|
||||||
|
expect(docs[0].client.address.line1).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works without a remembered folder", async () => {
|
||||||
|
const d = deps({ getLastExportDir: async () => Promise.reject(new Error("none")) });
|
||||||
|
const done = await exportInvoicesV1(d, [1], "json");
|
||||||
|
expect(done?.path).toBe("voiced-invoice-INV-2026-001.json");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* The generic `voiced.invoice.v1` export: a canonical, deterministic JSON document per invoice (and a CSV of line
|
||||||
|
* items) for software that Voiced has no direct integration with. Pure: it formats data it is handed, using integer
|
||||||
|
* paise for every amount, and never touches Tauri. The format is described in docs/voiced-invoice-v1.md.
|
||||||
|
*/
|
||||||
|
import { csvField, guardFormula, joinPath, paiseToDecimal } from "./historyExport";
|
||||||
|
import type { Payment } from "./payments";
|
||||||
|
import { INDIAN_STATES, type Client, type Invoice } from "./types";
|
||||||
|
|
||||||
|
export const INVOICE_SCHEMA = "voiced.invoice.v1";
|
||||||
|
|
||||||
|
/** Union territories without a legislature (mirror of `is_ut_without_legislature` in gst.rs): their second head is UTGST. */
|
||||||
|
const UT_WITHOUT_LEGISLATURE = new Set(["04", "26", "31", "35", "38"]);
|
||||||
|
|
||||||
|
export interface TaxHeadV1 {
|
||||||
|
/** Percent, e.g. "9" or "2.5"; "0" when the head does not apply to the invoice. */
|
||||||
|
rate: string;
|
||||||
|
amount_paise: number;
|
||||||
|
amount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceDocV1 {
|
||||||
|
schema: typeof INVOICE_SCHEMA;
|
||||||
|
number: string;
|
||||||
|
issue_date: string;
|
||||||
|
due_date: string | null;
|
||||||
|
document_type: "tax_invoice" | "invoice";
|
||||||
|
status: string;
|
||||||
|
cancelled: boolean;
|
||||||
|
cancelled_at: string | null;
|
||||||
|
cancel_reason: string | null;
|
||||||
|
currency: "INR";
|
||||||
|
po_number: string | null;
|
||||||
|
supplier: {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
gstin: string | null;
|
||||||
|
pan: string | null;
|
||||||
|
gst_registration: string;
|
||||||
|
state: { code: string; name: string };
|
||||||
|
};
|
||||||
|
client: {
|
||||||
|
name: string;
|
||||||
|
gstin: string | null;
|
||||||
|
gst_category: string;
|
||||||
|
address: {
|
||||||
|
text: string;
|
||||||
|
line1: string | null;
|
||||||
|
line2: string | null;
|
||||||
|
city: string | null;
|
||||||
|
pincode: string | null;
|
||||||
|
state: { code: string; name: string } | null;
|
||||||
|
};
|
||||||
|
place_of_supply: { code: string; name: string };
|
||||||
|
};
|
||||||
|
items: {
|
||||||
|
line: number;
|
||||||
|
description: string;
|
||||||
|
hsn_sac: string | null;
|
||||||
|
quantity: string;
|
||||||
|
unit: string | null;
|
||||||
|
rate_paise: number;
|
||||||
|
rate: string;
|
||||||
|
taxable_value_paise: number;
|
||||||
|
taxable_value: string;
|
||||||
|
}[];
|
||||||
|
tax: { cgst: TaxHeadV1; sgst: TaxHeadV1; utgst: TaxHeadV1; igst: TaxHeadV1 };
|
||||||
|
totals: {
|
||||||
|
subtotal_paise: number;
|
||||||
|
subtotal: string;
|
||||||
|
discount_paise: number;
|
||||||
|
discount: string;
|
||||||
|
taxable_value_paise: number;
|
||||||
|
taxable_value: string;
|
||||||
|
tax_paise: number;
|
||||||
|
tax: string;
|
||||||
|
total_paise: number;
|
||||||
|
total: string;
|
||||||
|
amount_in_words: string | null;
|
||||||
|
};
|
||||||
|
reverse_charge: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
payments: {
|
||||||
|
date: string;
|
||||||
|
amount_paise: number;
|
||||||
|
amount: string;
|
||||||
|
tds_paise: number;
|
||||||
|
tds: string;
|
||||||
|
mode: string;
|
||||||
|
reference: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rupees as stored (a REAL with two decimals) to integer paise, rounded exactly as the backend does. */
|
||||||
|
export function rupeesToPaise(rupees: number): number {
|
||||||
|
const paise = Math.round(rupees * 100);
|
||||||
|
if (!Number.isSafeInteger(paise)) throw new Error(`Not an amount: ${rupees}`);
|
||||||
|
return paise === 0 ? 0 : paise; // no negative zero
|
||||||
|
}
|
||||||
|
|
||||||
|
const blank = (s: string | null | undefined): string | null => {
|
||||||
|
const t = (s ?? "").trim();
|
||||||
|
return t === "" ? null : t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gstinOf = (s: string | null | undefined): string | null => {
|
||||||
|
const t = blank(s);
|
||||||
|
return t === null || t.toUpperCase() === "NA" ? null : t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateOf = (code: string): { code: string; name: string } => ({
|
||||||
|
code,
|
||||||
|
name: INDIAN_STATES.find((s) => s.code === code)?.name ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A percentage as the shortest decimal string (9, 6.25), without binary noise. */
|
||||||
|
export function rateString(rate: number): string {
|
||||||
|
return String(Number(rate.toFixed(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Snapshot {
|
||||||
|
vendorName?: string;
|
||||||
|
vendorAddress?: string;
|
||||||
|
vendorGstin?: string;
|
||||||
|
vendorPan?: string;
|
||||||
|
vendorStateCode?: string;
|
||||||
|
gstRegistration?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSnapshot(text: string): Snapshot {
|
||||||
|
try {
|
||||||
|
const v: unknown = text ? JSON.parse(text) : {};
|
||||||
|
return v && typeof v === "object" ? (v as Snapshot) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = (rate: number, rupees: number): TaxHeadV1 => {
|
||||||
|
const paise = rupeesToPaise(rupees);
|
||||||
|
return { rate: rateString(rate), amount_paise: paise, amount: paiseToDecimal(paise) };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One invoice as `voiced.invoice.v1`. The supplier comes from the snapshot frozen at issue. The client's name, GSTIN
|
||||||
|
* and place of supply are frozen on the invoice too; the structured address and GST category are read from the saved
|
||||||
|
* client record (`client`, optional) because the invoice only froze the printed text.
|
||||||
|
*/
|
||||||
|
export function buildInvoiceDoc(invoice: Invoice, payments: Payment[], client?: Client | null): InvoiceDocV1 {
|
||||||
|
const snap = parseSnapshot(invoice.vendorSnapshot);
|
||||||
|
const supplierState = snap.vendorStateCode ?? "";
|
||||||
|
const isUt = UT_WITHOUT_LEGISLATURE.has(supplierState);
|
||||||
|
const half = invoice.taxType === "cgst_sgst" ? invoice.taxRate / 2 : 0;
|
||||||
|
const igstRate = invoice.taxType === "igst" ? invoice.taxRate : 0;
|
||||||
|
|
||||||
|
const zero = (): TaxHeadV1 => head(0, 0);
|
||||||
|
const secondHead = head(half, invoice.sgstAmount);
|
||||||
|
const tax = {
|
||||||
|
cgst: head(half, invoice.cgstAmount),
|
||||||
|
sgst: isUt ? zero() : secondHead,
|
||||||
|
utgst: isUt ? secondHead : zero(),
|
||||||
|
igst: head(igstRate, invoice.igstAmount),
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtotal = rupeesToPaise(invoice.subtotal);
|
||||||
|
const discount = rupeesToPaise(invoice.discount);
|
||||||
|
const totalTax = tax.cgst.amount_paise + tax.sgst.amount_paise + tax.utgst.amount_paise + tax.igst.amount_paise;
|
||||||
|
const total = invoice.totalPaise ?? rupeesToPaise(invoice.total);
|
||||||
|
|
||||||
|
const gstin = gstinOf(invoice.clientGstin);
|
||||||
|
const pos = invoice.placeOfSupplyStateCode;
|
||||||
|
const clientState = client && client.stateCode ? stateOf(client.stateCode) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema: INVOICE_SCHEMA,
|
||||||
|
number: invoice.number,
|
||||||
|
issue_date: invoice.invoiceDate,
|
||||||
|
due_date: blank(invoice.dueDate),
|
||||||
|
document_type: invoice.docType,
|
||||||
|
status: invoice.status,
|
||||||
|
cancelled: invoice.status === "cancelled",
|
||||||
|
cancelled_at: invoice.status === "cancelled" ? blank(invoice.cancelledAt) : null,
|
||||||
|
cancel_reason: invoice.status === "cancelled" ? blank(invoice.cancelReason) : null,
|
||||||
|
currency: "INR",
|
||||||
|
po_number: blank(invoice.poNumber),
|
||||||
|
supplier: {
|
||||||
|
name: snap.vendorName ?? "",
|
||||||
|
address: snap.vendorAddress ?? "",
|
||||||
|
gstin: gstinOf(snap.vendorGstin),
|
||||||
|
pan: blank(snap.vendorPan),
|
||||||
|
gst_registration: snap.gstRegistration ?? "",
|
||||||
|
state: stateOf(supplierState),
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
name: invoice.clientName,
|
||||||
|
gstin,
|
||||||
|
gst_category: client?.gstCategory ?? (gstin ? "registered_regular" : "unregistered"),
|
||||||
|
address: {
|
||||||
|
text: invoice.clientAddress,
|
||||||
|
line1: blank(client?.addressLine1),
|
||||||
|
line2: blank(client?.addressLine2),
|
||||||
|
city: blank(client?.city),
|
||||||
|
pincode: blank(client?.pincode),
|
||||||
|
state: clientState,
|
||||||
|
},
|
||||||
|
place_of_supply: stateOf(pos),
|
||||||
|
},
|
||||||
|
items: invoice.items.map((item, index) => {
|
||||||
|
const fixed = item.mode === "fixed";
|
||||||
|
const amount = rupeesToPaise(item.amount);
|
||||||
|
const rate = fixed ? amount : rupeesToPaise(item.rate);
|
||||||
|
return {
|
||||||
|
line: index + 1,
|
||||||
|
description: item.description,
|
||||||
|
hsn_sac: blank(item.hsnSac),
|
||||||
|
quantity: fixed ? "1" : String(item.quantity),
|
||||||
|
unit: fixed ? null : item.unit,
|
||||||
|
rate_paise: rate,
|
||||||
|
rate: paiseToDecimal(rate),
|
||||||
|
taxable_value_paise: amount,
|
||||||
|
taxable_value: paiseToDecimal(amount),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
tax,
|
||||||
|
totals: {
|
||||||
|
subtotal_paise: subtotal,
|
||||||
|
subtotal: paiseToDecimal(subtotal),
|
||||||
|
discount_paise: discount,
|
||||||
|
discount: paiseToDecimal(discount),
|
||||||
|
taxable_value_paise: subtotal - discount,
|
||||||
|
taxable_value: paiseToDecimal(subtotal - discount),
|
||||||
|
tax_paise: totalTax,
|
||||||
|
tax: paiseToDecimal(totalTax),
|
||||||
|
total_paise: total,
|
||||||
|
total: paiseToDecimal(total),
|
||||||
|
amount_in_words: blank(invoice.amountInWords),
|
||||||
|
},
|
||||||
|
reverse_charge: invoice.reverseCharge,
|
||||||
|
notes: blank(invoice.notes),
|
||||||
|
payments: payments.map((p) => ({
|
||||||
|
date: p.paidOn,
|
||||||
|
amount_paise: p.amountPaise,
|
||||||
|
amount: paiseToDecimal(p.amountPaise),
|
||||||
|
tds_paise: p.tdsPaise,
|
||||||
|
tds: paiseToDecimal(p.tdsPaise),
|
||||||
|
mode: p.mode,
|
||||||
|
reference: blank(p.reference),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The JSON file: an array of invoice documents in the given order. Same input, same bytes. */
|
||||||
|
export function toInvoiceJson(docs: InvoiceDocV1[]): string {
|
||||||
|
return `${JSON.stringify(docs, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CSV: one row per line item ----
|
||||||
|
|
||||||
|
type Kind = "text" | "amount";
|
||||||
|
|
||||||
|
const LINE_COLUMNS: readonly { header: string; kind: Kind; value: (d: InvoiceDocV1, line: number) => string }[] = [
|
||||||
|
{ header: "invoice_number", kind: "text", value: (d) => d.number },
|
||||||
|
{ header: "issue_date", kind: "text", value: (d) => d.issue_date },
|
||||||
|
{ header: "due_date", kind: "text", value: (d) => d.due_date ?? "" },
|
||||||
|
{ header: "document_type", kind: "text", value: (d) => d.document_type },
|
||||||
|
{ header: "status", kind: "text", value: (d) => d.status },
|
||||||
|
{ header: "cancelled", kind: "text", value: (d) => (d.cancelled ? "yes" : "no") },
|
||||||
|
{ header: "currency", kind: "text", value: (d) => d.currency },
|
||||||
|
{ header: "supplier_name", kind: "text", value: (d) => d.supplier.name },
|
||||||
|
{ header: "supplier_gstin", kind: "text", value: (d) => d.supplier.gstin ?? "" },
|
||||||
|
{ header: "client_name", kind: "text", value: (d) => d.client.name },
|
||||||
|
{ header: "client_gstin", kind: "text", value: (d) => d.client.gstin ?? "" },
|
||||||
|
{ header: "client_gst_category", kind: "text", value: (d) => d.client.gst_category },
|
||||||
|
{ header: "place_of_supply_code", kind: "text", value: (d) => d.client.place_of_supply.code },
|
||||||
|
{ header: "place_of_supply", kind: "text", value: (d) => d.client.place_of_supply.name },
|
||||||
|
{ header: "reverse_charge", kind: "text", value: (d) => (d.reverse_charge ? "yes" : "no") },
|
||||||
|
{ header: "line", kind: "text", value: (d, i) => (d.items[i] ? String(d.items[i].line) : "") },
|
||||||
|
{ header: "description", kind: "text", value: (d, i) => d.items[i]?.description ?? "" },
|
||||||
|
{ header: "hsn_sac", kind: "text", value: (d, i) => d.items[i]?.hsn_sac ?? "" },
|
||||||
|
{ header: "quantity", kind: "text", value: (d, i) => d.items[i]?.quantity ?? "" },
|
||||||
|
{ header: "unit", kind: "text", value: (d, i) => d.items[i]?.unit ?? "" },
|
||||||
|
{ header: "rate", kind: "amount", value: (d, i) => d.items[i]?.rate ?? "" },
|
||||||
|
{ header: "taxable_value", kind: "amount", value: (d, i) => d.items[i]?.taxable_value ?? "" },
|
||||||
|
// Invoice-level amounts sit on the first row only, so summing a column never counts an invoice twice.
|
||||||
|
{ header: "invoice_discount", kind: "amount", value: (d, i) => (i === 0 ? d.totals.discount : "") },
|
||||||
|
{ header: "invoice_cgst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.cgst.amount : "") },
|
||||||
|
{ header: "invoice_sgst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.sgst.amount : "") },
|
||||||
|
{ header: "invoice_utgst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.utgst.amount : "") },
|
||||||
|
{ header: "invoice_igst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.igst.amount : "") },
|
||||||
|
{ header: "invoice_total", kind: "amount", value: (d, i) => (i === 0 ? d.totals.total : "") },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LINE_CSV_HEADERS: readonly string[] = LINE_COLUMNS.map((c) => c.header);
|
||||||
|
|
||||||
|
/** UTF-8 BOM, CRLF, header row always present; text cells are guarded against spreadsheet formulas. */
|
||||||
|
export function toLineItemsCsv(docs: InvoiceDocV1[]): string {
|
||||||
|
const lines: string[] = [LINE_COLUMNS.map((c) => csvField(c.header)).join(",")];
|
||||||
|
for (const doc of docs) {
|
||||||
|
const rows = Math.max(1, doc.items.length);
|
||||||
|
for (let i = 0; i < rows; i++) {
|
||||||
|
lines.push(LINE_COLUMNS.map((c) => csvField(c.kind === "text" ? guardFormula(c.value(doc, i)) : c.value(doc, i))).join(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${lines.join("\r\n")}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- saving ----
|
||||||
|
|
||||||
|
export type InvoiceExportFormat = "json" | "csv";
|
||||||
|
|
||||||
|
const safeName = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-");
|
||||||
|
|
||||||
|
export function invoiceExportName(docs: InvoiceDocV1[], format: InvoiceExportFormat, now: Date): string {
|
||||||
|
const base =
|
||||||
|
docs.length === 1
|
||||||
|
? `voiced-invoice-${safeName(docs[0].number)}`
|
||||||
|
: `voiced-invoices-${docs.length}-${now.toISOString().slice(0, 10)}`;
|
||||||
|
return `${base}${format === "csv" ? "-lines" : ""}.${format}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceExportDeps {
|
||||||
|
getInvoice(id: number): Promise<Invoice>;
|
||||||
|
listPayments(invoiceId: number): Promise<Payment[]>;
|
||||||
|
listClients(): Promise<Client[]>;
|
||||||
|
getLastExportDir(): Promise<string>;
|
||||||
|
save(opts: { defaultPath: string; format: InvoiceExportFormat }): Promise<string | null>;
|
||||||
|
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
|
||||||
|
now(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the invoices (frozen data plus payments) in the order given; the client record is optional detail. */
|
||||||
|
export async function loadInvoiceDocs(deps: Pick<InvoiceExportDeps, "getInvoice" | "listPayments" | "listClients">, ids: number[]): Promise<InvoiceDocV1[]> {
|
||||||
|
const unique = [...new Set(ids)];
|
||||||
|
const loaded: { invoice: Invoice; payments: Payment[] }[] = [];
|
||||||
|
for (const id of unique) {
|
||||||
|
const [invoice, payments] = await Promise.all([deps.getInvoice(id), deps.listPayments(id)]);
|
||||||
|
loaded.push({ invoice, payments });
|
||||||
|
}
|
||||||
|
let clients = new Map<number, Client>();
|
||||||
|
if (loaded.some((l) => l.invoice.clientId !== null)) {
|
||||||
|
try {
|
||||||
|
clients = new Map((await deps.listClients()).filter((c) => c.id !== null).map((c) => [c.id as number, c]));
|
||||||
|
} catch {
|
||||||
|
// The structured address is extra detail; the export is complete without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loaded.map(({ invoice, payments }) => buildInvoiceDoc(invoice, payments, invoice.clientId === null ? null : (clients.get(invoice.clientId) ?? null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exports the chosen invoices through the save dialog. Returns the path, or null if the dialog was cancelled. */
|
||||||
|
export async function exportInvoicesV1(deps: InvoiceExportDeps, ids: number[], format: InvoiceExportFormat): Promise<{ path: string; count: number } | null> {
|
||||||
|
const docs = await loadInvoiceDocs(deps, ids);
|
||||||
|
const text = format === "csv" ? toLineItemsCsv(docs) : toInvoiceJson(docs);
|
||||||
|
let dir = "";
|
||||||
|
try {
|
||||||
|
dir = await deps.getLastExportDir();
|
||||||
|
} catch {
|
||||||
|
// No remembered folder: the dialog opens wherever the system prefers.
|
||||||
|
}
|
||||||
|
const chosen = await deps.save({ defaultPath: joinPath(dir, invoiceExportName(docs, format, deps.now())), format });
|
||||||
|
if (!chosen) return null;
|
||||||
|
const path = (await deps.writeExportFile(chosen, new TextEncoder().encode(text))) || chosen;
|
||||||
|
return { path, count: docs.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatPaise, paiseToInput, parseRupeesToPaise } from "./money";
|
||||||
|
|
||||||
|
describe("money", () => {
|
||||||
|
it("parses typed amounts to exact paise", () => {
|
||||||
|
expect(parseRupeesToPaise("1250.5")).toBe(125050);
|
||||||
|
expect(parseRupeesToPaise("1,25,000")).toBe(12500000);
|
||||||
|
expect(parseRupeesToPaise("₹ 99.99")).toBe(9999);
|
||||||
|
expect(parseRupeesToPaise("0.29")).toBe(29);
|
||||||
|
expect(parseRupeesToPaise("19.9")).toBe(1990);
|
||||||
|
expect(parseRupeesToPaise("5.")).toBe(500);
|
||||||
|
expect(parseRupeesToPaise("")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-amounts", () => {
|
||||||
|
for (const bad of ["abc", "-5", "1.234", "1e3", "1.2.3", ".5"]) expect(parseRupeesToPaise(bad)).toBeNull();
|
||||||
|
expect(parseRupeesToPaise("99999999999999999999")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats", () => {
|
||||||
|
expect(paiseToInput(123450)).toBe("1234.50");
|
||||||
|
expect(paiseToInput(5)).toBe("0.05");
|
||||||
|
expect(formatPaise(12500000)).toBe("₹1,25,000.00");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { formatAmount } from "./format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses what a person types into an amount field ("1,25,000", "1250.5", "₹ 99.99") into integer paise
|
||||||
|
* without going through floating point. Returns null for anything that is not a plain amount with at
|
||||||
|
* most two decimals; an empty string is 0.
|
||||||
|
*/
|
||||||
|
export function parseRupeesToPaise(text: string): number | null {
|
||||||
|
const cleaned = text.replace(/[₹,\s]/g, "");
|
||||||
|
if (cleaned === "") return 0;
|
||||||
|
const m = /^(\d+)(?:\.(\d{0,2}))?$/.exec(cleaned);
|
||||||
|
if (!m) return null;
|
||||||
|
const whole = Number(m[1]);
|
||||||
|
const paise = whole * 100 + Number((m[2] ?? "").padEnd(2, "0"));
|
||||||
|
return Number.isSafeInteger(paise) ? paise : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 123450 -> "1234.50", for filling an amount field. */
|
||||||
|
export function paiseToInput(paise: number): string {
|
||||||
|
const p = Math.max(0, Math.round(paise));
|
||||||
|
return `${Math.floor(p / 100)}.${String(p % 100).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 123450 -> "₹1,234.50" */
|
||||||
|
export function formatPaise(paise: number): string {
|
||||||
|
return `₹${formatAmount(paise / 100)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { InvoiceStatus, PaymentStatus } from "./types";
|
||||||
|
|
||||||
|
export type PaymentMode = "bank_transfer" | "upi" | "cash" | "cheque" | "other";
|
||||||
|
|
||||||
|
export const PAYMENT_MODES: { id: PaymentMode; label: string }[] = [
|
||||||
|
{ id: "bank_transfer", label: "Bank transfer" },
|
||||||
|
{ id: "upi", label: "UPI" },
|
||||||
|
{ id: "cash", label: "Cash" },
|
||||||
|
{ id: "cheque", label: "Cheque" },
|
||||||
|
{ id: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const paymentModeLabel = (mode: string): string => PAYMENT_MODES.find((m) => m.id === mode)?.label ?? mode;
|
||||||
|
|
||||||
|
/** A payment as stored: amounts are integer paise. `amountPaise` is cash; `tdsPaise` is tax the client deducted. */
|
||||||
|
export interface Payment {
|
||||||
|
id: number;
|
||||||
|
invoiceId: number;
|
||||||
|
paidOn: string;
|
||||||
|
amountPaise: number;
|
||||||
|
tdsPaise: number;
|
||||||
|
mode: PaymentMode;
|
||||||
|
reference: string;
|
||||||
|
notes: string;
|
||||||
|
createdAt: string;
|
||||||
|
/** Payment Entry name on ERPNext once the payment was sent. */
|
||||||
|
erpnextPaymentEntry?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentInput = Omit<Payment, "id" | "createdAt" | "erpnextPaymentEntry">;
|
||||||
|
|
||||||
|
type TagType = "red" | "green" | "blue" | "gray" | "warm-gray" | "cool-gray" | "purple";
|
||||||
|
|
||||||
|
/** What to show in the status column and badges: the cancelled state wins over payment state. */
|
||||||
|
export function statusBadge(status: InvoiceStatus, payment: PaymentStatus | undefined): { label: string; type: TagType } {
|
||||||
|
if (status === "cancelled") return { label: "Cancelled", type: "gray" };
|
||||||
|
if (status === "draft") return { label: "Draft", type: "warm-gray" };
|
||||||
|
switch (payment) {
|
||||||
|
case "paid":
|
||||||
|
return { label: "Paid", type: "green" };
|
||||||
|
case "partially_paid":
|
||||||
|
return { label: "Partly paid", type: "blue" };
|
||||||
|
case "overdue":
|
||||||
|
return { label: "Overdue", type: "red" };
|
||||||
|
default:
|
||||||
|
return { label: "Unpaid", type: "cool-gray" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { itemFromPreset, paiseToRupees, rupeesToPaise } from "./presets";
|
||||||
|
import { blankPreset } from "./types";
|
||||||
|
|
||||||
|
describe("preset money helpers", () => {
|
||||||
|
it("rounds rupees to whole paise", () => {
|
||||||
|
expect(rupeesToPaise(1500.5)).toBe(150050);
|
||||||
|
expect(rupeesToPaise(0.1 + 0.2)).toBe(30);
|
||||||
|
expect(rupeesToPaise(19.99)).toBe(1999);
|
||||||
|
expect(rupeesToPaise(Number.NaN)).toBe(0);
|
||||||
|
expect(paiseToRupees(150050)).toBe(1500.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("itemFromPreset", () => {
|
||||||
|
it("fills a fixed line", () => {
|
||||||
|
const item = itemFromPreset({ ...blankPreset(), description: "Retainer", mode: "fixed", ratePaise: 250000, hsnSac: "9983" }, { hsnSac: "0000" });
|
||||||
|
expect(item).toMatchObject({ description: "Retainer", mode: "fixed", amount: 2500, rate: 0, quantity: 1, hsnSac: "9983" });
|
||||||
|
});
|
||||||
|
it("fills a rate line and falls back to the default HSN/SAC", () => {
|
||||||
|
const item = itemFromPreset({ ...blankPreset(), description: "Hours", mode: "rate", unit: "hour", ratePaise: 150050 }, { hsnSac: "998397" });
|
||||||
|
expect(item).toMatchObject({ mode: "rate", unit: "hour", rate: 1500.5, amount: 0, quantity: 1, hsnSac: "998397" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { InvoiceItem, ItemPreset } from "./types";
|
||||||
|
|
||||||
|
export const rupeesToPaise = (rupees: number): number => Math.round((Number(rupees) || 0) * 100);
|
||||||
|
export const paiseToRupees = (paise: number): number => paise / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new invoice line pre-filled from a preset. Invoice items store rupee floats, so the integer-paise
|
||||||
|
* preset amount is converted only here.
|
||||||
|
*/
|
||||||
|
export function itemFromPreset(preset: ItemPreset, defaults: { hsnSac: string }): InvoiceItem {
|
||||||
|
const rupees = paiseToRupees(preset.ratePaise);
|
||||||
|
return {
|
||||||
|
description: preset.description,
|
||||||
|
mode: preset.mode,
|
||||||
|
rate: preset.mode === "rate" ? rupees : 0,
|
||||||
|
unit: preset.unit,
|
||||||
|
quantity: 1,
|
||||||
|
amount: preset.mode === "fixed" ? rupees : 0,
|
||||||
|
sortOrder: 0,
|
||||||
|
hsnSac: preset.hsnSac || defaults.hsnSac,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import type { BankAccount, Invoice, InvoiceInput, Settings } from "./types";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The end-to-end invoice flow through the real IPC, run against the fresh database the self-test
|
* The end-to-end invoice flow through the real IPC, run against the fresh database the self-test
|
||||||
* launches with (it seeds the unregistered vendor, a default bank and an active series).
|
* launches with. The fresh database is neutral (no vendor, no bank), so the first step provisions a
|
||||||
|
* fake vendor and bank the way the setup wizard would.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const today = () => {
|
const today = () => {
|
||||||
@@ -28,11 +29,32 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
|
|||||||
let bank!: BankAccount;
|
let bank!: BankAccount;
|
||||||
let invoice: Invoice | null = null;
|
let invoice: Invoice | null = null;
|
||||||
|
|
||||||
const loaded = await log.run("e2e: get settings and default bank", async () => {
|
const loaded = await log.run("e2e: provision vendor and bank, get settings", async () => {
|
||||||
s = await api.getSettings();
|
s = await api.getSettings();
|
||||||
const banks = await api.listBanks();
|
let banks = await api.listBanks();
|
||||||
|
if (banks.length === 0) {
|
||||||
|
const saved = await api.saveBank({
|
||||||
|
id: null,
|
||||||
|
label: "Primary",
|
||||||
|
bankName: "Self-test Bank",
|
||||||
|
accountName: "Self-test Vendor",
|
||||||
|
accountNo: "000000000000",
|
||||||
|
branch: "000001",
|
||||||
|
ifsc: "TEST0000001",
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
s = await api.saveSettings({
|
||||||
|
...s,
|
||||||
|
vendorName: s.vendorName || "Self-test Vendor",
|
||||||
|
vendorAddress: s.vendorAddress || "1 Test Road, Bengaluru - 560001",
|
||||||
|
vendorStateCode: s.vendorStateCode || "29",
|
||||||
|
defaultBankId: saved.id,
|
||||||
|
onboarded: true,
|
||||||
|
});
|
||||||
|
banks = await api.listBanks();
|
||||||
|
}
|
||||||
const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0];
|
const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0];
|
||||||
if (!found) throw new Error("the fresh database has no bank account");
|
if (!found) throw new Error("no bank account after provisioning");
|
||||||
bank = found;
|
bank = found;
|
||||||
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
|
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, requestSettingsTab, saveSettingsTab } from "./settingsTab";
|
||||||
|
|
||||||
|
describe("settings tab persistence", () => {
|
||||||
|
it("maps stored ids to indexes and falls back to the first tab", () => {
|
||||||
|
expect(parseSettingsTab("fonts")).toBe(SETTINGS_TABS.findIndex((t) => t.id === "fonts"));
|
||||||
|
expect(parseSettingsTab("nope")).toBe(0);
|
||||||
|
expect(parseSettingsTab(null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw when storage is unavailable", () => {
|
||||||
|
// The node test environment has no localStorage at all.
|
||||||
|
expect(loadSettingsTab()).toBe(0);
|
||||||
|
expect(() => saveSettingsTab(2)).not.toThrow();
|
||||||
|
expect(() => saveSettingsTab(99)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours a requested tab once, then goes back to the stored one", () => {
|
||||||
|
requestSettingsTab("integrations");
|
||||||
|
expect(loadSettingsTab()).toBe(SETTINGS_TABS.findIndex((t) => t.id === "integrations"));
|
||||||
|
expect(loadSettingsTab()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const KEY = "voiced.settingsTab";
|
||||||
|
|
||||||
|
export const SETTINGS_TABS = [
|
||||||
|
{ id: "business", label: "Business" },
|
||||||
|
{ id: "branding", label: "Branding" },
|
||||||
|
{ id: "bank", label: "Bank & payments" },
|
||||||
|
{ id: "defaults", label: "Invoice defaults" },
|
||||||
|
{ id: "numbering", label: "Numbering" },
|
||||||
|
{ id: "templates", label: "Templates & export" },
|
||||||
|
{ id: "fonts", label: "Fonts" },
|
||||||
|
{ id: "data", label: "Data" },
|
||||||
|
{ id: "integrations", label: "Integrations" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
|
||||||
|
|
||||||
|
/** Index of a stored tab id; 0 (Business) for anything unknown. */
|
||||||
|
export function parseSettingsTab(value: string | null | undefined): number {
|
||||||
|
const index = SETTINGS_TABS.findIndex((t) => t.id === value);
|
||||||
|
return index < 0 ? 0 : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tab another screen asked for; used once by the next Settings mount, so it works without localStorage. */
|
||||||
|
let requested: SettingsTabId | null = null;
|
||||||
|
|
||||||
|
/** Makes the next opening of Settings land on `id` (for "Set up in Settings" links). */
|
||||||
|
export function requestSettingsTab(id: SettingsTabId): void {
|
||||||
|
requested = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last Settings tab this viewer used. A per-viewer convenience only. */
|
||||||
|
export function loadSettingsTab(): number {
|
||||||
|
if (requested) {
|
||||||
|
const index = parseSettingsTab(requested);
|
||||||
|
requested = null;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parseSettingsTab(localStorage.getItem(KEY));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettingsTab(index: number): void {
|
||||||
|
try {
|
||||||
|
const tab = SETTINGS_TABS[index];
|
||||||
|
if (tab) localStorage.setItem(KEY, tab.id);
|
||||||
|
} catch {
|
||||||
|
// Not remembering is harmless.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type ThemeName = "g10" | "g100";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "voiced.theme";
|
||||||
|
|
||||||
|
export const normalizeTheme = (value: string | null | undefined): ThemeName => (value === "g100" ? "g100" : "g10");
|
||||||
|
|
||||||
|
/** The theme last applied on this machine; used before settings load so there is no light flash. */
|
||||||
|
export function cachedTheme(): ThemeName {
|
||||||
|
try {
|
||||||
|
return normalizeTheme(localStorage.getItem(STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return "g10";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the Carbon theme tokens on <html> as well as on the <Theme> zone, so the page canvas, the
|
||||||
|
* overscroll area and any window area below the content follow the chosen theme.
|
||||||
|
* index.html runs the same class switch before React mounts.
|
||||||
|
*/
|
||||||
|
export function applyDocumentTheme(theme: ThemeName): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove("cds--g10", "cds--g100");
|
||||||
|
root.classList.add(`cds--${theme}`);
|
||||||
|
root.dataset.carbonTheme = theme;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
} catch {
|
||||||
|
/* storage may be blocked; the class is already applied */
|
||||||
|
}
|
||||||
|
}
|
||||||