Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc4d85e14 | ||
|
|
2ffbf0f66c | ||
|
|
d3b5fbb1b8 | ||
|
|
86b4d436fa | ||
|
|
92f952b136 | ||
|
|
1623b879e2 | ||
|
|
3510cc4260 | ||
|
|
9e17ba3479 |
Vendored
+2
@@ -156,3 +156,5 @@ Built as `src/pdf/templates/monolith/`: a standalone template (`tokens.ts` the t
|
||||
- **Elastic gaps.** The 88 pt band under the client (down to 24), the row padding (down to 6), the gaps above BILLED TO and under the client block (down to 14) and the two gaps in the footer (down to 8) give back Letter's missing 50 pt, and the overflow of an invoice that is a little too long for one page. A 2-item tax invoice with a bank block fits one A4 page that way; with 3 rows and a signature image the closing block moves to page 2 (the items and totals stay together on page 1).
|
||||
- **Amount in words and reverse charge** are the totals' left column (Medium 10.5, x 35..329). **Notes** and the **signature** (an image up to 150 x 45, or the signatory name and designation and the computer-generated line) form the band above the footer's top rule; notes taller than 110 pt flow as their own block before the closing.
|
||||
- **Footer position.** The closing block ends 24 pt above the "Generated with Voiced" line, so its bottom rule is at y 804 where the reference design's is at 812.
|
||||
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (band, info gaps, row padding) alone, as in Marble: the footer's own two gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 4 or 5 rows, Letter with 4).
|
||||
- **Flow notes guard.** The flow-notes block is preceded by a zero-height `TableGuard` asking for 84 pt (14 gap + 70 hold), so it never starts with less than its label and first line of room and collapses into zero-height lines at a page foot; it starts on the next page instead (Marble's guard).
|
||||
|
||||
Vendored
+1
@@ -155,4 +155,5 @@ Built as `src/pdf/templates/cobalt/`: a standalone template (`tokens.ts` the typ
|
||||
- **Totals.** "Sub Total :", Discount, Taxable Value and the CGST / SGST / IGST rows (labelled "CGST 9% :") end at the label column's right edge (x 417.4 of the design); the amount in words and the reverse-charge line sit under the GRAND TOTAL bar, right-aligned in 9.5 pt grey. The PAYMENT METHOD bar is level with the GRAND TOTAL bar as in the design whenever the invoice still fits its page that way; with a longer stack (a GST invoice has up to five rows against the design's two) it moves up beside the first row, which gives back up to the stack's height (104 pt for five rows), and the bank lines follow it.
|
||||
- **Closing.** The divider, "Thank you for business with us!" and the terms (the notes; taller than 110 pt they flow as a block of their own before the closing) at the left, the signature at the right, then the icon footer: the footer rule with accents at both ends, and phone, e-mail and address with Carbon icons (26 pt boxes, sky). The footer is part of the closing block, so it is on the last page only, not on every page, and everything in it is audited. The contacts keep their natural width at 32.5 pt apart; when they do not fit, the address wraps into what is left and the phone and e-mail shrink only if the address would get under 120 pt.
|
||||
- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): header to client (42 to 26 pt), client to table (30.5 to 16), last row to the totals (9.8 to 4), thank-you to terms (26 to 14) and terms to the footer rule (16 to 8). Type is never scaled.
|
||||
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (header to client, client to table, last row to totals) alone, as in Marble: the terms and footer gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 7 rows).
|
||||
- **Margins.** 54.2 pt at the top (the INVOICE line box starts there so its baseline lands at 87.2), 59.55 at the sides and 35.5 plus the 24 pt footer reserve at the foot, which puts the icons' bottom 59.5 pt above the page foot as in the design.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# voiced.invoice.v1
|
||||
|
||||
A neutral export of issued invoices for software Voiced has no direct integration with. Choose invoices in the
|
||||
History list (checkboxes) or open one, then use "Export selected (voiced.invoice.v1 JSON)" or "(line items CSV)".
|
||||
The code is `src/lib/invoiceExportV1.ts`; `src/lib/__golden__/invoice-v1.golden.json` is a full example.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Money is exact.** Every amount appears twice: `<name>_paise` (an integer) and `<name>` (a decimal string with two
|
||||
places, e.g. `"7310.10"`). Nothing is a float. Rates (percent) are decimal strings such as `"9"` or `"6.25"`.
|
||||
- **Deterministic.** The same invoices give the same bytes: fixed key order, no timestamps, input order kept.
|
||||
- **JSON file** is an array with one object per invoice; each object carries `"schema": "voiced.invoice.v1"`.
|
||||
- Cancelled invoices are included with `"cancelled": true` (their figures are kept; filter them out of sums).
|
||||
- An unregistered supplier's plain "Invoice" has `document_type: "invoice"`, all tax heads zero and no supplier GSTIN.
|
||||
|
||||
## Invoice object
|
||||
|
||||
| Key | Meaning |
|
||||
| --- | --- |
|
||||
| `schema`, `number`, `issue_date`, `due_date` | `YYYY-MM-DD` dates; `due_date` is null when unset |
|
||||
| `document_type` | `tax_invoice` or `invoice` |
|
||||
| `status`, `cancelled`, `cancelled_at`, `cancel_reason` | `status` is `issued` or `cancelled` |
|
||||
| `currency`, `po_number` | always `INR` |
|
||||
| `supplier` | `name`, `address`, `gstin` (null if none), `pan`, `gst_registration`, `state {code, name}`; frozen at issue |
|
||||
| `client` | `name`, `gstin`, `gst_category`, `address {text, line1, line2, city, pincode, state}`, `place_of_supply {code, name}` |
|
||||
| `items[]` | `line`, `description`, `hsn_sac`, `quantity` (decimal string), `unit`, `rate`, `taxable_value` (each with `_paise`) |
|
||||
| `tax` | `cgst`, `sgst`, `utgst`, `igst`, each `{rate, amount_paise, amount}`; heads that do not apply are zero |
|
||||
| `totals` | `subtotal`, `discount`, `taxable_value`, `tax`, `total` (each with `_paise`), `amount_in_words` |
|
||||
| `reverse_charge`, `notes` | |
|
||||
| `payments[]` | `date`, `amount` (cash), `tds`, `mode`, `reference` (amounts with `_paise`) |
|
||||
|
||||
Notes on the fields:
|
||||
|
||||
- **Items.** `taxable_value` of a line is the line amount before the invoice-level discount. `totals.taxable_value` is
|
||||
`subtotal - discount`, the base the tax is charged on. A fixed-amount line has `quantity "1"` and `unit null`.
|
||||
- **UTGST.** When the supplier is in a union territory without a legislature (state codes 04, 26, 31, 35, 38) the second
|
||||
head is reported under `utgst` and `sgst` is zero.
|
||||
- **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured
|
||||
address parts and `gst_category` come from the client's saved record at export time (null or derived from the GSTIN
|
||||
when the invoice has no saved client).
|
||||
- **Supplier snapshot.** Only name, address, GSTIN, PAN, registration and state are exported; logo paths, e-mail and
|
||||
phone are not.
|
||||
|
||||
## Line-items CSV
|
||||
|
||||
One row per line item, UTF-8 with a byte order mark and CRLF line ends; text cells that a spreadsheet would read as a
|
||||
formula get a leading apostrophe. Columns: `invoice_number, issue_date, due_date, document_type, status, cancelled,
|
||||
currency, supplier_name, supplier_gstin, client_name, client_gstin, client_gst_category, place_of_supply_code,
|
||||
place_of_supply, reverse_charge, line, description, hsn_sac, quantity, unit, rate, taxable_value, invoice_discount,
|
||||
invoice_cgst, invoice_sgst, invoice_utgst, invoice_igst, invoice_total`. The `invoice_*` amounts are filled on the first
|
||||
row of each invoice only, so summing a column never counts an invoice twice.
|
||||
@@ -0,0 +1,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:
|
||||
Executable
+42
@@ -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()
|
||||
Generated
+421
-8
@@ -509,6 +509,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -532,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -545,10 +555,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -875,6 +891,20 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
"multiversion_no_op",
|
||||
"rustversion",
|
||||
"scopeguard",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1244,6 +1274,17 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1415,6 +1456,25 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.14.2",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1531,6 +1591,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1541,6 +1602,21 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.21"
|
||||
@@ -1560,9 +1636,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1891,6 +1969,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.21",
|
||||
"walkdir",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -2090,6 +2198,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2152,6 +2270,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2439,6 +2563,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2866,21 +2996,31 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2916,6 +3056,20 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
@@ -2979,12 +3133,91 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2994,6 +3227,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -3051,6 +3293,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.38.0"
|
||||
@@ -3175,6 +3440,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.24.0"
|
||||
@@ -3272,6 +3549,22 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
@@ -3396,6 +3689,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.8"
|
||||
@@ -3459,6 +3758,27 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3480,7 +3800,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -3490,7 +3810,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -3546,7 +3866,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -3763,7 +4083,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -3786,7 +4106,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -3972,9 +4292,31 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
@@ -3984,6 +4326,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -4188,6 +4531,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -4211,6 +4560,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
@@ -4223,6 +4578,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4293,8 +4654,10 @@ dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"rusqlite_migration",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -4306,7 +4669,9 @@ dependencies = [
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4508,6 +4873,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.39.1"
|
||||
@@ -4672,6 +5046,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -4699,6 +5084,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -4988,7 +5382,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -5149,6 +5543,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
@@ -5182,6 +5582,19 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap 2.14.2",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -28,8 +28,14 @@ thiserror = "2"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
flate2 = "1"
|
||||
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
|
||||
percent-encoding = "2"
|
||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
# ERPNext integration: all HTTP runs in Rust. TLS is rustls with the `ring` provider only (no aws-lc,
|
||||
# which does not cross-compile cleanly under cargo-xwin); roots come from the OS via rustls-platform-verifier.
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "http2", "charset", "system-proxy", "json", "multipart", "query"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
|
||||
# Desktop-only plugins (not available on mobile targets).
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
@@ -38,6 +44,7 @@ tauri-plugin-window-state = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["rt", "macros", "net", "io-util", "time"] }
|
||||
|
||||
[features]
|
||||
# Production builds embed the frontend and serve it over the custom asset
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,16 @@
|
||||
use crate::gst;
|
||||
use crate::models::Client;
|
||||
use crate::AppState;
|
||||
use rusqlite::{params, Row};
|
||||
use rusqlite::{params, Connection, Row};
|
||||
use tauri::State;
|
||||
|
||||
const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"];
|
||||
|
||||
const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
|
||||
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
|
||||
c.payment_terms_days,
|
||||
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
|
||||
|
||||
fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
||||
Ok(Client {
|
||||
id: Some(row.get(0)?),
|
||||
@@ -12,17 +20,51 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
||||
state_code: row.get(4)?,
|
||||
po_number: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
address_line1: row.get(7)?,
|
||||
address_line2: row.get(8)?,
|
||||
city: row.get(9)?,
|
||||
pincode: row.get(10)?,
|
||||
gst_category: row.get(11)?,
|
||||
default_notes: row.get(12)?,
|
||||
payment_terms_days: row.get(13)?,
|
||||
invoice_count: row.get(14)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
|
||||
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
|
||||
pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String {
|
||||
let state_name = gst::STATES
|
||||
.iter()
|
||||
.find(|(c, _)| *c == state_code)
|
||||
.map(|(_, n)| *n)
|
||||
.unwrap_or("");
|
||||
let place = [city.trim(), state_name]
|
||||
.iter()
|
||||
.filter(|p| !p.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let pin = pincode.trim();
|
||||
let last = match (place.is_empty(), pin.is_empty()) {
|
||||
(false, false) => format!("{place} - {pin}"),
|
||||
(false, true) => place,
|
||||
(true, false) => pin.to_string(),
|
||||
(true, true) => String::new(),
|
||||
};
|
||||
[line1.trim(), line2.trim(), last.as_str()]
|
||||
.iter()
|
||||
.filter(|l| !l.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn list_clients_impl(conn: &Connection) -> Result<Vec<Client>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, address, gstin, state_code, po_number, created_at
|
||||
FROM clients ORDER BY name COLLATE NOCASE ASC",
|
||||
)
|
||||
.prepare(&format!(
|
||||
"SELECT {CLIENT_COLS} FROM clients c ORDER BY c.name COLLATE NOCASE ASC"
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], map_client)
|
||||
@@ -32,29 +74,101 @@ pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
pub fn save_client_impl(conn: &Connection, client: Client) -> Result<Client, String> {
|
||||
let name = client.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("Client name is required".into());
|
||||
}
|
||||
let gstin = match client.gstin.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "NA" => String::new(),
|
||||
g => {
|
||||
gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?;
|
||||
g.to_string()
|
||||
}
|
||||
};
|
||||
let state_code = client.state_code.trim().to_string();
|
||||
if !state_code.is_empty() && !gst::state_exists(&state_code) {
|
||||
return Err(format!("\"{state_code}\" is not a valid GST state code"));
|
||||
}
|
||||
if !gstin.is_empty() && !state_code.is_empty() && gstin[0..2] != state_code {
|
||||
return Err(format!(
|
||||
"GSTIN starts with state code {}, but the client's state is {}",
|
||||
&gstin[0..2],
|
||||
state_code
|
||||
));
|
||||
}
|
||||
if !CATEGORIES.contains(&client.gst_category.as_str()) {
|
||||
return Err(format!("Unknown GST category \"{}\"", client.gst_category));
|
||||
}
|
||||
let pincode = client.pincode.trim().to_string();
|
||||
if !pincode.is_empty() && !(pincode.len() == 6 && pincode.bytes().all(|b| b.is_ascii_digit())) {
|
||||
return Err("Pincode must be 6 digits".into());
|
||||
}
|
||||
if let Some(days) = client.payment_terms_days {
|
||||
if !(0..=365).contains(&days) {
|
||||
return Err("Payment terms must be between 0 and 365 days".into());
|
||||
}
|
||||
}
|
||||
let line1 = client.address_line1.trim().to_string();
|
||||
let line2 = client.address_line2.trim().to_string();
|
||||
let city = client.city.trim().to_string();
|
||||
// The structured parts win when present; otherwise the incoming (legacy or free-text) address is kept.
|
||||
let address = if line1.is_empty() && line2.is_empty() && city.is_empty() && pincode.is_empty() {
|
||||
client.address.clone()
|
||||
} else {
|
||||
compose_address(&line1, &line2, &city, &state_code, &pincode)
|
||||
};
|
||||
|
||||
let id = match client.id {
|
||||
Some(id) => {
|
||||
conn.execute(
|
||||
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6",
|
||||
params![client.name, client.address, client.gstin, client.state_code, client.po_number, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let n = conn
|
||||
.execute(
|
||||
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5,
|
||||
address_line1=?6, address_line2=?7, city=?8, pincode=?9, gst_category=?10,
|
||||
default_notes=?11, payment_terms_days=?12
|
||||
WHERE id=?13",
|
||||
params![
|
||||
name,
|
||||
address,
|
||||
gstin,
|
||||
state_code,
|
||||
client.po_number,
|
||||
line1,
|
||||
line2,
|
||||
city,
|
||||
pincode,
|
||||
client.gst_category,
|
||||
client.default_notes,
|
||||
client.payment_terms_days,
|
||||
id
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Err("Client not found".into());
|
||||
}
|
||||
id
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at,
|
||||
address_line1, address_line2, city, pincode, gst_category, default_notes,
|
||||
payment_terms_days)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
client.name,
|
||||
client.address,
|
||||
client.gstin,
|
||||
client.state_code,
|
||||
name,
|
||||
address,
|
||||
gstin,
|
||||
state_code,
|
||||
client.po_number,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
chrono::Utc::now().to_rfc3339(),
|
||||
line1,
|
||||
line2,
|
||||
city,
|
||||
pincode,
|
||||
client.gst_category,
|
||||
client.default_notes,
|
||||
client.payment_terms_days
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -62,17 +176,176 @@ pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, Str
|
||||
}
|
||||
};
|
||||
conn.query_row(
|
||||
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1",
|
||||
&format!("SELECT {CLIENT_COLS} FROM clients c WHERE c.id = ?1"),
|
||||
params![id],
|
||||
map_client,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
/// Deleting is allowed even when invoices reference the client: invoices carry their own
|
||||
/// name/address/GSTIN snapshot and `invoices.client_id` has no foreign key, so issued invoices are
|
||||
/// untouched (their id simply no longer resolves). The UI warns with `invoice_count` first.
|
||||
pub fn delete_client_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_clients_impl(&conn)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
save_client_impl(&conn, client)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
delete_client_impl(&conn, id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::open_in_memory;
|
||||
|
||||
fn blank(name: &str) -> Client {
|
||||
serde_json::from_value(serde_json::json!({ "id": null, "name": name })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_address_orders_parts() {
|
||||
assert_eq!(
|
||||
compose_address("12 MG Road", "Floor 2", "Bengaluru", "29", "560001"),
|
||||
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
|
||||
);
|
||||
assert_eq!(compose_address("", "", "", "", ""), "");
|
||||
assert_eq!(compose_address(" A ", "", "", "", "560001"), "A\n560001");
|
||||
assert_eq!(compose_address("", "", "Pune", "", ""), "Pune");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_round_trip_composes_address() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let mut c = blank("Acme");
|
||||
c.address_line1 = "12 MG Road".into();
|
||||
c.address_line2 = "Floor 2".into();
|
||||
c.city = "Bengaluru".into();
|
||||
c.pincode = "560001".into();
|
||||
c.state_code = "29".into();
|
||||
c.gstin = "29abcde1234f1zw".into();
|
||||
c.gst_category = "registered_regular".into();
|
||||
c.default_notes = "Net 15".into();
|
||||
c.payment_terms_days = Some(15);
|
||||
c.po_number = "PO-9".into();
|
||||
let saved = save_client_impl(&conn, c).unwrap();
|
||||
assert!(saved.id.is_some());
|
||||
assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
||||
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
|
||||
let listed = list_clients_impl(&conn).unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
let l = &listed[0];
|
||||
assert_eq!(l.address_line1, "12 MG Road");
|
||||
assert_eq!(l.gst_category, "registered_regular");
|
||||
assert_eq!(l.default_notes, "Net 15");
|
||||
assert_eq!(l.payment_terms_days, Some(15));
|
||||
assert_eq!(l.invoice_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_address_is_kept_when_no_structured_parts() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let mut c = blank("Legacy");
|
||||
c.address = "Old style\nfree text".into();
|
||||
let saved = save_client_impl(&conn, c).unwrap();
|
||||
assert_eq!(saved.address, "Old style\nfree text");
|
||||
assert_eq!(saved.gst_category, "unregistered");
|
||||
// Editing a field other than the address leaves it alone.
|
||||
let mut edit = saved.clone();
|
||||
edit.po_number = "PO-1".into();
|
||||
let again = save_client_impl(&conn, edit).unwrap();
|
||||
assert_eq!(again.address, "Old style\nfree text");
|
||||
assert_eq!(again.id, saved.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_changes_the_row_and_unknown_id_fails() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let saved = save_client_impl(&conn, blank("A")).unwrap();
|
||||
let mut edit = saved.clone();
|
||||
edit.name = "B".into();
|
||||
edit.gst_category = "sez".into();
|
||||
let again = save_client_impl(&conn, edit).unwrap();
|
||||
assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez"));
|
||||
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||
let mut ghost = blank("Ghost");
|
||||
ghost.id = Some(999);
|
||||
assert!(save_client_impl(&conn, ghost).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_bad_input() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("GSTIN"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gstin = "29ABCDE1234F1ZW".into();
|
||||
c.state_code = "27".into();
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("state"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gstin = "NA".into();
|
||||
assert_eq!(save_client_impl(&conn, c).unwrap().gstin, "");
|
||||
|
||||
let mut c = blank("X");
|
||||
c.state_code = "99".into();
|
||||
assert!(save_client_impl(&conn, c).is_err());
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gst_category = "weird".into();
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("category"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.pincode = "56001".into();
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("Pincode"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.payment_terms_days = Some(400);
|
||||
assert!(save_client_impl(&conn, c).is_err());
|
||||
// Only the valid "NA" client above was stored; every rejected save left no row behind.
|
||||
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_keeps_referencing_invoices_and_count_is_listed() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let saved = save_client_impl(&conn, blank("Referenced")).unwrap();
|
||||
let id = saved.id.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, client_id, client_name, client_address, created_at, updated_at)
|
||||
VALUES ('T-1', '2026-01-01', ?1, 'Referenced', 'Snap addr', 'now', 'now')",
|
||||
params![id],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(list_clients_impl(&conn).unwrap()[0].invoice_count, 1);
|
||||
|
||||
delete_client_impl(&conn, id).unwrap();
|
||||
assert!(list_clients_impl(&conn).unwrap().is_empty());
|
||||
let (name, addr): (String, String) = conn
|
||||
.query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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 crate::db::format_number;
|
||||
use crate::gst::{self, TaxType};
|
||||
use super::payments::{derive_summary, summary_for, today_ist};
|
||||
use crate::models::{
|
||||
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
|
||||
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
|
||||
};
|
||||
use crate::AppState;
|
||||
use chrono::NaiveDate;
|
||||
@@ -59,6 +60,8 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||
archived_fingerprint: row.get(35)?,
|
||||
archived_at: row.get(36)?,
|
||||
items: Vec::new(),
|
||||
payment: PaymentSummary::default(),
|
||||
total_paise: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,6 +93,8 @@ fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
|
||||
map_invoice,
|
||||
)?;
|
||||
invoice.items = fetch_items(conn, id)?;
|
||||
invoice.total_paise = gst::rupees_to_paise(invoice.total);
|
||||
invoice.payment = summary_for(conn, id, &invoice.status, invoice.total, &invoice.due_date)?;
|
||||
Ok(invoice)
|
||||
}
|
||||
|
||||
@@ -338,15 +343,16 @@ pub fn issue_invoice_impl(
|
||||
Some(id) => Some(id),
|
||||
None if input.save_client && !input.client_name.trim().is_empty() => {
|
||||
tx.execute(
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at, gst_category)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
input.client_name,
|
||||
input.client_address,
|
||||
client_gstin,
|
||||
stored_pos,
|
||||
input.po_number,
|
||||
now
|
||||
now,
|
||||
if client_gstin.is_empty() { "unregistered" } else { "registered_regular" }
|
||||
],
|
||||
)
|
||||
.map_err(db)?;
|
||||
@@ -459,6 +465,12 @@ pub fn issue_invoice(
|
||||
|
||||
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
||||
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
|
||||
let payments: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if payments > 0 {
|
||||
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
|
||||
}
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let changed = conn
|
||||
.execute(
|
||||
@@ -490,23 +502,42 @@ pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result
|
||||
#[tauri::command]
|
||||
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_invoices_impl(&conn)
|
||||
}
|
||||
|
||||
/// One query: invoices left-joined to their payment sums. Status derivation happens in Rust
|
||||
/// (`derive_summary`) so list and detail agree.
|
||||
pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, String> {
|
||||
let today = today_ist();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, number, invoice_date, due_date, client_name, total, status, created_at
|
||||
FROM invoices ORDER BY id DESC",
|
||||
"SELECT i.id, i.number, i.invoice_date, i.due_date, i.client_name, i.total, i.status,
|
||||
i.created_at, i.po_number, COALESCE(p.paid, 0), COALESCE(p.tds, 0)
|
||||
FROM invoices i
|
||||
LEFT JOIN (SELECT invoice_id, SUM(amount_paise) AS paid, SUM(tds_paise) AS tds
|
||||
FROM payments GROUP BY invoice_id) p ON p.invoice_id = i.id
|
||||
ORDER BY i.id DESC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let total: f64 = row.get(5)?;
|
||||
let status: String = row.get(6)?;
|
||||
let due_date: String = row.get(3)?;
|
||||
let total_paise = gst::rupees_to_paise(total);
|
||||
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today);
|
||||
Ok(InvoiceSummary {
|
||||
id: row.get(0)?,
|
||||
number: row.get(1)?,
|
||||
invoice_date: row.get(2)?,
|
||||
due_date: row.get(3)?,
|
||||
due_date,
|
||||
client_name: row.get(4)?,
|
||||
total: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
total,
|
||||
status,
|
||||
created_at: row.get(7)?,
|
||||
po_number: row.get(8)?,
|
||||
total_paise,
|
||||
payment,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -515,10 +546,91 @@ pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, Stri
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
|
||||
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InvoiceLedgerRow {
|
||||
pub id: i64,
|
||||
pub client_gstin: String,
|
||||
pub place_of_supply_code: String,
|
||||
pub doc_type: String,
|
||||
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
|
||||
pub hsn_sac: Vec<String>,
|
||||
/// Subtotal less discount.
|
||||
pub taxable_paise: i64,
|
||||
pub cgst_paise: i64,
|
||||
pub sgst_paise: i64,
|
||||
pub igst_paise: i64,
|
||||
pub reverse_charge: bool,
|
||||
}
|
||||
|
||||
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
|
||||
{
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let items = stmt
|
||||
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
for item in items {
|
||||
let (id, code) = item.map_err(|e| e.to_string())?;
|
||||
let list = hsn_by_invoice.entry(id).or_default();
|
||||
if !code.is_empty() && !list.contains(&code) {
|
||||
list.push(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
|
||||
cgst_amount, sgst_amount, igst_amount, reverse_charge
|
||||
FROM invoices ORDER BY id DESC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let id: i64 = r.get(0)?;
|
||||
Ok(InvoiceLedgerRow {
|
||||
id,
|
||||
client_gstin: r.get(1)?,
|
||||
place_of_supply_code: r.get(2)?,
|
||||
doc_type: r.get(3)?,
|
||||
hsn_sac: Vec::new(),
|
||||
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
|
||||
cgst_paise: gst::rupees_to_paise(r.get(6)?),
|
||||
sgst_paise: gst::rupees_to_paise(r.get(7)?),
|
||||
igst_paise: gst::rupees_to_paise(r.get(8)?),
|
||||
reverse_charge: r.get::<_, i64>(9)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|mut row| {
|
||||
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
|
||||
row
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_invoice_ledger_impl(&conn)
|
||||
}
|
||||
|
||||
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
|
||||
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
fetch_invoice(&conn, id).map_err(|e| e.to_string())
|
||||
get_invoice_impl(&conn, id)
|
||||
}
|
||||
|
||||
pub fn save_draft_impl(
|
||||
@@ -661,6 +773,28 @@ mod tests {
|
||||
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]
|
||||
fn issues_numbered_tax_invoices_with_derived_totals() {
|
||||
let mut conn = registered();
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
pub mod archive;
|
||||
pub mod assets;
|
||||
pub mod backup;
|
||||
pub mod clients;
|
||||
pub mod erpnext;
|
||||
pub mod files;
|
||||
pub mod fonts;
|
||||
pub mod invoice;
|
||||
pub mod logo;
|
||||
pub mod payments;
|
||||
pub mod presets;
|
||||
pub mod raw;
|
||||
pub mod series;
|
||||
pub mod settings;
|
||||
|
||||
@@ -0,0 +1,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());
|
||||
}
|
||||
}
|
||||
+326
-5
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||
|
||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||
const LATEST_VERSION: i64 = 6;
|
||||
pub(crate) const LATEST_VERSION: i64 = 11;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
@@ -217,8 +217,148 @@ CREATE INDEX idx_user_fonts_sha ON user_fonts(sha256);
|
||||
CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0;
|
||||
"#;
|
||||
|
||||
/// Version 7: structured client addresses, GST category and per-client defaults, plus saved item presets.
|
||||
/// `clients.address` stays the composed multi-line string (issued invoices and the PDF read it); the new
|
||||
/// parts are an editing aid. The address state is `clients.state_code`, which is also the default place
|
||||
/// of supply, so there is no second state column. `payment_terms_days` NULL means "use the settings default".
|
||||
/// `item_presets.rate_paise` is the fixed amount for mode 'fixed' and the per-unit rate for mode 'rate'.
|
||||
const M7: &str = r#"
|
||||
ALTER TABLE clients ADD COLUMN address_line1 TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN address_line2 TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN city TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN pincode TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN gst_category TEXT NOT NULL DEFAULT 'unregistered'
|
||||
CHECK (gst_category IN ('registered_regular', 'unregistered', 'composition', 'sez', 'overseas'));
|
||||
ALTER TABLE clients ADD COLUMN default_notes TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN payment_terms_days INTEGER;
|
||||
UPDATE clients SET gst_category = 'registered_regular' WHERE gstin NOT IN ('', 'NA');
|
||||
|
||||
CREATE TABLE item_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
description TEXT NOT NULL,
|
||||
hsn_sac TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'fixed' CHECK (mode IN ('fixed', 'rate')),
|
||||
unit TEXT NOT NULL DEFAULT 'unit' CHECK (unit IN ('second', 'minute', 'hour', 'session', 'unit')),
|
||||
rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"#;
|
||||
|
||||
/// Version 8: payments received against issued invoices. Amounts are integer paise. `amount_paise` is the cash
|
||||
/// received and `tds_paise` the tax the client deducted at source; both count towards settling the invoice.
|
||||
/// Payment status is derived from these rows and the due date, never stored, and the issued invoice row is untouched.
|
||||
const M8: &str = r#"
|
||||
CREATE TABLE payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invoice_id INTEGER NOT NULL REFERENCES invoices(id),
|
||||
paid_on TEXT NOT NULL,
|
||||
amount_paise INTEGER NOT NULL DEFAULT 0 CHECK (amount_paise >= 0),
|
||||
tds_paise INTEGER NOT NULL DEFAULT 0 CHECK (tds_paise >= 0),
|
||||
mode TEXT NOT NULL CHECK (mode IN ('bank_transfer', 'upi', 'cash', 'cheque', 'other')),
|
||||
reference TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
CHECK (amount_paise + tds_paise > 0)
|
||||
);
|
||||
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
|
||||
"#;
|
||||
|
||||
/// Version 9: `auto_backup` switches the daily automatic backup (see commands::backup). On by default. It is read and
|
||||
/// written through its own commands, not through `Settings`, so a settings save can never clobber it.
|
||||
const M9: &str = r#"
|
||||
ALTER TABLE app_settings ADD COLUMN auto_backup INTEGER NOT NULL DEFAULT 1;
|
||||
"#;
|
||||
|
||||
/// Version 10: ERPNext integration. `erpnext_config` is a single row (inserted here, so reading it is a plain SELECT)
|
||||
/// holding the connection, the company/account/master mapping and the toggles. `api_secret` is stored here and is never
|
||||
/// returned to the webview. `erpnext_sync` tracks what was pushed per invoice; issued invoices themselves are untouched.
|
||||
/// The remote names on clients, payments and item presets are nullable: NULL means "not linked yet".
|
||||
const M10: &str = r#"
|
||||
CREATE TABLE erpnext_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
api_secret TEXT NOT NULL DEFAULT '',
|
||||
extra_ca_pem TEXT NOT NULL DEFAULT '',
|
||||
company TEXT NOT NULL DEFAULT '',
|
||||
company_address TEXT NOT NULL DEFAULT '',
|
||||
naming_mode TEXT NOT NULL DEFAULT 'mirror' CHECK (naming_mode IN ('mirror', 'series')),
|
||||
naming_series TEXT NOT NULL DEFAULT '',
|
||||
income_account TEXT NOT NULL DEFAULT '',
|
||||
cost_center TEXT NOT NULL DEFAULT '',
|
||||
cgst_account TEXT NOT NULL DEFAULT '',
|
||||
sgst_account TEXT NOT NULL DEFAULT '',
|
||||
utgst_account TEXT NOT NULL DEFAULT '',
|
||||
igst_account TEXT NOT NULL DEFAULT '',
|
||||
tax_template_intra TEXT NOT NULL DEFAULT '',
|
||||
tax_template_inter TEXT NOT NULL DEFAULT '',
|
||||
payment_bank_account TEXT NOT NULL DEFAULT '',
|
||||
tds_account TEXT NOT NULL DEFAULT '',
|
||||
default_item_code TEXT NOT NULL DEFAULT '',
|
||||
uom_map TEXT NOT NULL DEFAULT '{"second":"Second","minute":"Minute","hour":"Hour","session":"Nos","unit":"Nos"}',
|
||||
customer_group TEXT NOT NULL DEFAULT '',
|
||||
territory TEXT NOT NULL DEFAULT '',
|
||||
selling_price_list TEXT NOT NULL DEFAULT '',
|
||||
submit_on_push INTEGER NOT NULL DEFAULT 0,
|
||||
attach_pdf INTEGER NOT NULL DEFAULT 1,
|
||||
auto_push_on_issue INTEGER NOT NULL DEFAULT 0,
|
||||
create_missing_customers INTEGER NOT NULL DEFAULT 1,
|
||||
last_detect_result TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT
|
||||
);
|
||||
INSERT INTO erpnext_config (id) VALUES (1);
|
||||
|
||||
CREATE TABLE erpnext_sync (
|
||||
invoice_id INTEGER PRIMARY KEY REFERENCES invoices(id),
|
||||
remote_name TEXT NOT NULL DEFAULT '',
|
||||
remote_docstatus INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'error')),
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
payload_hash TEXT NOT NULL DEFAULT '',
|
||||
synced_at TEXT
|
||||
);
|
||||
|
||||
ALTER TABLE clients ADD COLUMN erpnext_customer TEXT;
|
||||
ALTER TABLE clients ADD COLUMN erpnext_address TEXT;
|
||||
ALTER TABLE payments ADD COLUMN erpnext_payment_entry TEXT;
|
||||
ALTER TABLE item_presets ADD COLUMN erpnext_item_code TEXT;
|
||||
"#;
|
||||
|
||||
/// Version 11: ERPNext push state. `erpnext_sync.status` gains `conflict` (the remote document exists with a different
|
||||
/// total; we never overwrite it) and `attachment_sha256` records which archived PDF was uploaded so it is never attached
|
||||
/// twice. SQLite cannot alter a CHECK constraint, so the table is rebuilt; M10 rows are copied over.
|
||||
const M11: &str = r#"
|
||||
CREATE TABLE erpnext_sync_new (
|
||||
invoice_id INTEGER PRIMARY KEY REFERENCES invoices(id),
|
||||
remote_name TEXT NOT NULL DEFAULT '',
|
||||
remote_docstatus INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'error', 'conflict')),
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
payload_hash TEXT NOT NULL DEFAULT '',
|
||||
synced_at TEXT,
|
||||
attachment_sha256 TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO erpnext_sync_new (invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at)
|
||||
SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync;
|
||||
DROP TABLE erpnext_sync;
|
||||
ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync;
|
||||
"#;
|
||||
|
||||
fn migrations() -> Migrations<'static> {
|
||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)])
|
||||
Migrations::new(vec![
|
||||
M::up(SCHEMA),
|
||||
M::up(M2),
|
||||
M::up(M3),
|
||||
M::up(M4),
|
||||
M::up(M5),
|
||||
M::up(M6),
|
||||
M::up(M7),
|
||||
M::up(M8),
|
||||
M::up(M9),
|
||||
M::up(M10),
|
||||
M::up(M11),
|
||||
])
|
||||
}
|
||||
|
||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||
@@ -238,7 +378,14 @@ pub fn open(path: &Path, backup_dir: &Path) -> Result<Connection, DbError> {
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
/// Write a consistent, compacted snapshot of the open database to `target` (which must not exist yet).
|
||||
/// Safe while other connections read and write: SQLite takes a read transaction for the copy.
|
||||
pub(crate) fn vacuum_into(conn: &Connection, target: &Path) -> rusqlite::Result<()> {
|
||||
let quoted = target.to_string_lossy().replace('\'', "''");
|
||||
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))
|
||||
}
|
||||
|
||||
pub(crate) fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
||||
[],
|
||||
@@ -256,8 +403,7 @@ fn backup_before_migration(
|
||||
fs::create_dir_all(backup_dir)?;
|
||||
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
||||
let quoted = target.to_string_lossy().replace('\'', "''");
|
||||
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
|
||||
vacuum_into(conn, &target)?;
|
||||
// A failed prune must not block startup; the backup itself already succeeded.
|
||||
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
||||
Ok(target)
|
||||
@@ -657,6 +803,181 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m7_fresh_database_has_client_and_preset_columns() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
conn.prepare(
|
||||
"SELECT address_line1, address_line2, city, pincode, gst_category, default_notes, payment_terms_days FROM clients",
|
||||
)
|
||||
.unwrap();
|
||||
conn.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at FROM item_presets")
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO clients (name, created_at) VALUES ('X', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let (cat, terms): (String, Option<i64>) = conn
|
||||
.query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.unwrap();
|
||||
assert_eq!((cat.as_str(), terms), ("unregistered", None));
|
||||
// The category CHECK rejects unknown values.
|
||||
assert!(conn
|
||||
.execute("UPDATE clients SET gst_category = 'bogus'", [])
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)];
|
||||
Migrations::new(all).to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 6);
|
||||
conn.execute(
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||
VALUES ('Acme', 'Line one\nLine two', '29ABCDE1234F1Z5', '29', 'PO-1', 'now'),
|
||||
('Plain', '', '', '', '', 'now'),
|
||||
('Na', '', 'NA', '', '', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrations().to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||
|
||||
type Row = (String, String, String, String, String, String, String);
|
||||
let rows: Vec<Row> = conn
|
||||
.prepare("SELECT name, address, gstin, state_code, po_number, gst_category, address_line1 FROM clients ORDER BY id")
|
||||
.unwrap()
|
||||
.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(rows[0].1, "Line one\nLine two");
|
||||
assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1"));
|
||||
assert_eq!(rows[0].5, "registered_regular");
|
||||
assert_eq!(rows[0].6, "");
|
||||
assert_eq!(rows[1].5, "unregistered");
|
||||
assert_eq!(rows[2].5, "unregistered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_fresh_database_has_the_erpnext_tables_and_a_default_config_row() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let (naming, submit, attach, auto_push, create_customers, secret, uom): (String, i64, i64, i64, i64, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT naming_mode, submit_on_push, attach_pdf, auto_push_on_issue, create_missing_customers, api_secret, uom_map
|
||||
FROM erpnext_config WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((naming.as_str(), submit, attach, auto_push, create_customers), ("mirror", 0, 1, 0, 1));
|
||||
assert_eq!(secret, "");
|
||||
assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok());
|
||||
let rows: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(rows, 1);
|
||||
// Single row only.
|
||||
assert!(conn.execute("INSERT INTO erpnext_config (id) VALUES (2)", []).is_err());
|
||||
conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync")
|
||||
.unwrap();
|
||||
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients").unwrap();
|
||||
conn.prepare("SELECT erpnext_payment_entry FROM payments").unwrap();
|
||||
conn.prepare("SELECT erpnext_item_code FROM item_presets").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let v9 = vec![
|
||||
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9),
|
||||
];
|
||||
Migrations::new(v9).to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 9);
|
||||
conn.execute("INSERT INTO clients (name, gstin, created_at) VALUES ('Acme', '29ABCDE1234F1Z5', 'now')", []).unwrap();
|
||||
conn.execute("INSERT INTO item_presets (description, rate_paise, created_at) VALUES ('Design', 500000, 'now')", []).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO payments (invoice_id, paid_on, amount_paise, mode, created_at) VALUES (1, '2026-04-10', 12345, 'upi', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrations().to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||
|
||||
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) = conn
|
||||
.query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), gstin.as_str(), customer, address), ("Acme", "29ABCDE1234F1Z5", None, None));
|
||||
let (desc, rate, code): (String, i64, Option<String>) = conn
|
||||
.query_row("SELECT description, rate_paise, erpnext_item_code FROM item_presets", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
|
||||
let (paid, entry): (i64, Option<String>) = conn
|
||||
.query_row("SELECT amount_paise, erpnext_payment_entry FROM payments", [], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.unwrap();
|
||||
assert_eq!((paid, entry), (12345, None));
|
||||
let number: String = conn.query_row("SELECT number FROM invoices", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(number, "INV/2026-001");
|
||||
|
||||
// The config row exists with defaults, and a sync row can reference the invoice.
|
||||
let enabled: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config WHERE id = 1", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(enabled, 1);
|
||||
conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap();
|
||||
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", []).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let v10 = vec![
|
||||
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), M::up(M10),
|
||||
];
|
||||
Migrations::new(v10).to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 10);
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO erpnext_sync (invoice_id, remote_name, remote_docstatus, status, payload_hash, synced_at)
|
||||
VALUES (1, 'INV/2026-001', 1, 'synced', 'abc', '2026-04-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err());
|
||||
|
||||
migrations().to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 11);
|
||||
let row: (String, i64, String, String, String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
row,
|
||||
("INV/2026-001".into(), 1, "synced".into(), "abc".into(), String::new(), Some("2026-04-02T00:00:00Z".into()))
|
||||
);
|
||||
conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).unwrap();
|
||||
assert!(conn.execute("UPDATE erpnext_sync SET status = 'bogus'", []).is_err());
|
||||
// The unique key still guards one row per invoice.
|
||||
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", []).is_err());
|
||||
}
|
||||
|
||||
fn build_v1_database(path: &Path) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||
|
||||
@@ -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/AP%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:?}");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 db;
|
||||
mod gst;
|
||||
mod integrations;
|
||||
mod logo;
|
||||
mod models;
|
||||
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));
|
||||
return Err(e.into());
|
||||
}
|
||||
// A restore staged by the Data tab is swapped in now, before anything opens the database.
|
||||
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
|
||||
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
||||
commands::backup::ApplyOutcome::NothingPending => {}
|
||||
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
||||
eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
|
||||
}
|
||||
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
|
||||
}
|
||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
@@ -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) {
|
||||
eprintln!("Could not derive the logo images: {e}");
|
||||
}
|
||||
commands::backup::spawn_auto_backup(dirs);
|
||||
Ok(AppState {
|
||||
db: Mutex::new(conn),
|
||||
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)]
|
||||
pub fn run() {
|
||||
// After "Restart" in the Data tab the new process waits for the old one (and its single-instance lock) to go.
|
||||
commands::backup::wait_if_relaunched();
|
||||
|
||||
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
||||
// combinations. Respect an explicit user setting, otherwise turn it off.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -121,6 +135,9 @@ pub fn run() {
|
||||
commands::clients::list_clients,
|
||||
commands::clients::save_client,
|
||||
commands::clients::delete_client,
|
||||
commands::presets::list_item_presets,
|
||||
commands::presets::save_item_preset,
|
||||
commands::presets::delete_item_preset,
|
||||
commands::invoice::peek_next_invoice_number,
|
||||
commands::invoice::issue_invoice,
|
||||
commands::invoice::cancel_invoice,
|
||||
@@ -130,6 +147,16 @@ pub fn run() {
|
||||
commands::invoice::list_drafts,
|
||||
commands::invoice::get_draft,
|
||||
commands::invoice::delete_draft,
|
||||
commands::payments::record_payment,
|
||||
commands::payments::list_payments,
|
||||
commands::payments::delete_payment,
|
||||
commands::backup::create_backup,
|
||||
commands::backup::restore_backup,
|
||||
commands::backup::cancel_pending_restore,
|
||||
commands::backup::get_backup_status,
|
||||
commands::backup::set_auto_backup,
|
||||
commands::backup::restart_app,
|
||||
commands::invoice::list_invoice_ledger,
|
||||
commands::assets::import_asset,
|
||||
commands::assets::save_asset_bytes,
|
||||
commands::assets::read_asset_data_uri,
|
||||
@@ -150,6 +177,16 @@ pub fn run() {
|
||||
commands::files::get_last_export_dir,
|
||||
commands::files::reveal_in_folder,
|
||||
commands::files::open_file,
|
||||
commands::erpnext::erpnext_get_config,
|
||||
commands::erpnext::erpnext_save_config,
|
||||
commands::erpnext::erpnext_test_connection,
|
||||
commands::erpnext::erpnext_load_options,
|
||||
commands::erpnext::erpnext_push_invoice,
|
||||
commands::erpnext::erpnext_push_invoices,
|
||||
commands::erpnext::erpnext_push_payment,
|
||||
commands::erpnext::erpnext_sync_status,
|
||||
commands::erpnext::erpnext_sync_statuses,
|
||||
commands::erpnext::erpnext_open_url,
|
||||
selftest::selftest_config,
|
||||
selftest::selftest_report,
|
||||
])
|
||||
|
||||
@@ -95,6 +95,50 @@ pub struct Client {
|
||||
pub po_number: String,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub address_line1: String,
|
||||
#[serde(default)]
|
||||
pub address_line2: String,
|
||||
#[serde(default)]
|
||||
pub city: String,
|
||||
#[serde(default)]
|
||||
pub pincode: String,
|
||||
/// registered_regular | unregistered | composition | sez | overseas. Informational only.
|
||||
#[serde(default = "default_gst_category")]
|
||||
pub gst_category: String,
|
||||
/// Notes / payment terms text copied onto a new invoice when this client is picked.
|
||||
#[serde(default)]
|
||||
pub default_notes: String,
|
||||
/// None = use the settings default.
|
||||
#[serde(default)]
|
||||
pub payment_terms_days: Option<i64>,
|
||||
/// Number of invoices that reference this client; read-only, filled by list_clients.
|
||||
#[serde(default)]
|
||||
pub invoice_count: i64,
|
||||
}
|
||||
|
||||
fn default_gst_category() -> String {
|
||||
"unregistered".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ItemPreset {
|
||||
pub id: Option<i64>,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub hsn_sac: String,
|
||||
/// "fixed" = rate_paise is the line amount; "rate" = rate_paise is the rate per unit.
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
#[serde(default = "default_unit")]
|
||||
pub unit: String,
|
||||
#[serde(default)]
|
||||
pub rate_paise: i64,
|
||||
#[serde(default)]
|
||||
pub sort_order: i64,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -221,6 +265,34 @@ pub struct Invoice {
|
||||
pub archived_fingerprint: Option<String>,
|
||||
pub archived_at: Option<String>,
|
||||
pub items: Vec<InvoiceItem>,
|
||||
/// Derived from the payments table (never stored on the invoice): see commands::payments.
|
||||
#[serde(flatten)]
|
||||
pub payment: PaymentSummary,
|
||||
/// `total` in integer paise.
|
||||
#[serde(default)]
|
||||
pub total_paise: i64,
|
||||
}
|
||||
|
||||
/// What has been received against an invoice. All amounts are integer paise; `payment_status` is
|
||||
/// unpaid | partially_paid | paid | overdue, or `none` for an invoice that takes no payments.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaymentSummary {
|
||||
pub paid_paise: i64,
|
||||
pub tds_paise: i64,
|
||||
pub balance_paise: i64,
|
||||
pub payment_status: String,
|
||||
}
|
||||
|
||||
impl Default for PaymentSummary {
|
||||
fn default() -> Self {
|
||||
PaymentSummary {
|
||||
paid_paise: 0,
|
||||
tds_paise: 0,
|
||||
balance_paise: 0,
|
||||
payment_status: "none".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -234,6 +306,10 @@ pub struct InvoiceSummary {
|
||||
pub total: f64,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub po_number: String,
|
||||
pub total_paise: i64,
|
||||
#[serde(flatten)]
|
||||
pub payment: PaymentSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
+48
-5
@@ -6,14 +6,29 @@ import { AppShell, type AppView } from "./components/AppShell";
|
||||
import Onboarding from "./views/Onboarding";
|
||||
import NewInvoice from "./views/NewInvoice";
|
||||
import InvoiceHistory from "./views/InvoiceHistory";
|
||||
import SeriesSettings from "./views/SeriesSettings";
|
||||
import InvoiceDetail from "./views/InvoiceDetail";
|
||||
import Clients from "./views/Clients";
|
||||
import { ToastProvider, useToast } from "./components/ToastProvider";
|
||||
import AppSettings from "./views/AppSettings";
|
||||
import { requestSettingsTab } from "./lib/settingsTab";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AppInner />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [view, setView] = useState<AppView>("new");
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
// A draft made by "Duplicate" that the New invoice form should open once it is shown.
|
||||
const [openDraftId, setOpenDraftId] = useState<number | null>(null);
|
||||
|
||||
const refreshSettings = useCallback(async () => {
|
||||
try {
|
||||
@@ -75,13 +90,18 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const openIntegrations = () => {
|
||||
requestSettingsTab("integrations");
|
||||
setView("settings");
|
||||
};
|
||||
|
||||
const toggleTheme = async () => {
|
||||
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
|
||||
setSettings(next);
|
||||
try {
|
||||
await api.saveSettings(next);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not save the theme", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,12 +114,35 @@ export default function App() {
|
||||
onSettingsChange={refreshSettings}
|
||||
active={view === "new"}
|
||||
onActivate={() => setView("new")}
|
||||
openDraftId={openDraftId}
|
||||
onDraftOpened={() => setOpenDraftId(null)}
|
||||
/>
|
||||
<div hidden={view !== "invoices"}>
|
||||
<InvoiceHistory settings={settings} active={view === "invoices"} />
|
||||
<InvoiceHistory
|
||||
settings={settings}
|
||||
active={view === "invoices"}
|
||||
onOpenSettings={openIntegrations}
|
||||
onOpen={(id) => {
|
||||
setDetailId(id);
|
||||
setView("invoice");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div hidden={view !== "series"}>
|
||||
<SeriesSettings active={view === "series"} />
|
||||
{view === "invoice" && detailId !== null ? (
|
||||
<InvoiceDetail
|
||||
key={detailId}
|
||||
invoiceId={detailId}
|
||||
settings={settings}
|
||||
onBack={() => setView("invoices")}
|
||||
onOpenSettings={openIntegrations}
|
||||
onDuplicated={(draftId) => {
|
||||
setOpenDraftId(draftId);
|
||||
setView("new");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div hidden={view !== "clients"}>
|
||||
<Clients active={view === "clients"} />
|
||||
</div>
|
||||
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
||||
</AppShell>
|
||||
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
import { Asleep, Light } from "@carbon/icons-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type AppView = "new" | "invoices" | "series" | "settings";
|
||||
/** "invoice" is the detail page of one invoice; it is reached from "invoices" and highlights that tab. */
|
||||
export type AppView = "new" | "invoices" | "invoice" | "clients" | "settings";
|
||||
|
||||
const NAV: { id: AppView; label: string }[] = [
|
||||
const NAV: { id: Exclude<AppView, "invoice">; label: string }[] = [
|
||||
{ id: "new", label: "New invoice" },
|
||||
{ id: "invoices", label: "Invoices" },
|
||||
{ id: "series", label: "Series" },
|
||||
{ id: "clients", label: "Clients" },
|
||||
{ id: "settings", label: "Settings" },
|
||||
];
|
||||
|
||||
@@ -49,7 +50,7 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
||||
{NAV.map((item) => (
|
||||
<HeaderMenuItem
|
||||
key={item.id}
|
||||
isCurrentPage={view === item.id}
|
||||
isCurrentPage={view === item.id || (view === "invoice" && item.id === "invoices")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onNavigate(item.id);
|
||||
|
||||
@@ -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,278 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
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}`;
|
||||
|
||||
/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */
|
||||
export default function DataBackupPanel() {
|
||||
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 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>
|
||||
|
||||
<Modal
|
||||
open={restorePath !== null}
|
||||
danger={step === "confirm"}
|
||||
modalHeading={step === "ready" ? "Restart to finish restoring" : "Restore from this backup?"}
|
||||
primaryButtonText={
|
||||
step === "confirm" ? "Check and restore" : step === "staging" ? "Checking the backup…" : restarting ? "Restarting…" : "Restart now"
|
||||
}
|
||||
secondaryButtonText={step === "ready" ? "Later" : "Cancel"}
|
||||
primaryButtonDisabled={step === "staging" || restarting}
|
||||
onRequestClose={closeRestore}
|
||||
onRequestSubmit={() => void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
|
||||
onSecondarySubmit={closeRestore}
|
||||
>
|
||||
<div className="voiced-stack">
|
||||
{restoreError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Cannot restore" subtitle={restoreError} /> : null}
|
||||
{step === "ready" && staged ? (
|
||||
<>
|
||||
<p>
|
||||
<strong>{staged.backupName}</strong> passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
|
||||
{formatWhen(staged.backupCreatedAt)}.
|
||||
</p>
|
||||
<p>
|
||||
Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
|
||||
when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
File: <strong>{restorePath}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
|
||||
contents of this backup.
|
||||
</p>
|
||||
<ul className="voiced-list">
|
||||
<li>The file is checked first; a damaged or newer-version backup is refused and nothing changes.</li>
|
||||
<li>Your current data is moved to a safety copy in Voiced’s backups folder before the swap. It is never deleted.</li>
|
||||
<li>Voiced restarts to complete the restore.</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ComboBox,
|
||||
FileUploaderButton,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
PasswordInput,
|
||||
RadioButton,
|
||||
RadioButtonGroup,
|
||||
TextArea,
|
||||
TextInput,
|
||||
Tag,
|
||||
Tile,
|
||||
Toggle,
|
||||
} from "@carbon/react";
|
||||
import { Save } from "@carbon/icons-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { ErpnextConfig, ErpnextConfigInput, ErpnextConnectionTest, ErpnextOptionItem, ErpnextOptions } from "../lib/erpnext";
|
||||
import {
|
||||
OPTION_LIST_LABELS,
|
||||
UOM_UNITS,
|
||||
connectionProblems,
|
||||
emptyOptions,
|
||||
hasProblems,
|
||||
mirrorSupported,
|
||||
optionErrors,
|
||||
optionText,
|
||||
sortWarnings,
|
||||
toInput,
|
||||
validateConfigForm,
|
||||
versionsLine,
|
||||
warningSeverity,
|
||||
withStoredValue,
|
||||
type FormProblems,
|
||||
} from "../lib/erpnextUi";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
|
||||
|
||||
interface PickProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** The loaded list; an empty list means "not loaded", and the field takes typed text instead. */
|
||||
items: ErpnextOptionItem[];
|
||||
/** Why this list is empty although the lists were loaded (a load error for this list). */
|
||||
error?: string;
|
||||
/** The control is switched off, with the reason shown under it. */
|
||||
disabledReason?: string;
|
||||
helper?: string;
|
||||
}
|
||||
|
||||
/** A searchable dropdown over a live list, or a text box while the list has not been loaded. */
|
||||
function PickField({ id, label, value, onChange, items, error, disabledReason, helper }: PickProps) {
|
||||
const options = useMemo(() => withStoredValue(items, value), [items, value]);
|
||||
const note = disabledReason ?? error ?? helper;
|
||||
if (items.length === 0 || disabledReason) {
|
||||
return (
|
||||
<TextInput
|
||||
id={id}
|
||||
labelText={label}
|
||||
value={value}
|
||||
disabled={Boolean(disabledReason)}
|
||||
invalid={Boolean(error) && !disabledReason}
|
||||
invalidText={error}
|
||||
helperText={error ? undefined : note}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ComboBox
|
||||
id={id}
|
||||
titleText={label}
|
||||
items={options}
|
||||
itemToString={(i: ErpnextOptionItem | null) => (i ? optionText(i) : "")}
|
||||
selectedItem={options.find((o) => o.name === value) ?? null}
|
||||
onChange={({ selectedItem }: { selectedItem?: ErpnextOptionItem | null }) => onChange(selectedItem?.name ?? "")}
|
||||
shouldFilterItem={({ item, inputValue }: { item: ErpnextOptionItem; inputValue: string | null }) =>
|
||||
!inputValue || optionText(item).toLowerCase().includes(inputValue.toLowerCase())
|
||||
}
|
||||
helperText={helper}
|
||||
placeholder="Search or choose"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children, note }: { title: string; children: React.ReactNode; note?: string }) {
|
||||
return (
|
||||
<section className="voiced-erp__section" aria-label={title}>
|
||||
<h3>{title}</h3>
|
||||
{note ? <p className="voiced-muted">{note}</p> : null}
|
||||
<div className="voiced-erp__fields">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings, Integrations, ERPNext. It has its own Save: the page-level Save settings button does not cover it. */
|
||||
export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
|
||||
const toast = useToast();
|
||||
const [saved, setSaved] = useState<ErpnextConfig | null>(null);
|
||||
const [form, setForm] = useState<ErpnextConfigInput | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const [test, setTest] = useState<ErpnextConnectionTest | null>(null);
|
||||
/** A test result from this session that is not saved yet. */
|
||||
const [testUnsaved, setTestUnsaved] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [options, setOptions] = useState<ErpnextOptions | null>(null);
|
||||
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showProblems, setShowProblems] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await api.erpnextGetConfig();
|
||||
setSaved(cfg);
|
||||
setForm(toInput(cfg));
|
||||
setTest(cfg.lastDetectResult);
|
||||
setTestUnsaved(false);
|
||||
setLoadError(null);
|
||||
} catch (e) {
|
||||
setLoadError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && form === null) void load();
|
||||
// Reload only on first show; edits must survive switching tabs.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
|
||||
if (loadError) {
|
||||
return <InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the ERPNext settings" subtitle={loadError} />;
|
||||
}
|
||||
if (!form || !saved) return <InlineLoading description="Loading ERPNext settings" />;
|
||||
|
||||
const set = <K extends keyof ErpnextConfigInput>(key: K, value: ErpnextConfigInput[K]) => setForm((f) => (f ? { ...f, [key]: value } : f));
|
||||
const secretStored = saved.apiSecretSet;
|
||||
const secretShown = secretStored && !replacing && !form.clearSecret;
|
||||
const problems: FormProblems = showProblems ? validateConfigForm(form, secretStored, test) : {};
|
||||
const connProblems = connectionProblems(form, secretStored);
|
||||
const canConnect = !hasProblems(connProblems);
|
||||
const optErrors = optionErrors(options);
|
||||
const opts = options ?? emptyOptions();
|
||||
const noCompany = !form.company.trim();
|
||||
const companyReason = noCompany ? "Pick a company first." : undefined;
|
||||
const seriesMode = form.namingMode === "series";
|
||||
const mirrorOk = mirrorSupported(test);
|
||||
|
||||
const loadOptions = async (values: ErpnextConfigInput) => {
|
||||
setLoadingOptions(true);
|
||||
try {
|
||||
setOptions(await api.erpnextLoadOptions(values));
|
||||
} catch (e) {
|
||||
setOptions(null);
|
||||
toast.error("Could not load the ERPNext lists", String(e));
|
||||
} finally {
|
||||
setLoadingOptions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
setShowProblems(true);
|
||||
if (!canConnect) return;
|
||||
setTesting(true);
|
||||
setTestError(null);
|
||||
try {
|
||||
const result = await api.erpnextTestConnection(form);
|
||||
setTest(result);
|
||||
setTestUnsaved(true);
|
||||
let next = form;
|
||||
// A site that keeps no posted name cannot mirror the Voiced number.
|
||||
if (!result.features.v2Naming && form.namingMode === "mirror") {
|
||||
next = { ...form, namingMode: "series" };
|
||||
setForm(next);
|
||||
}
|
||||
await loadOptions(next);
|
||||
} catch (e) {
|
||||
setTestError(String(e));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeCompany = (company: string) => {
|
||||
const next = { ...form, company };
|
||||
setForm(next);
|
||||
// Accounts, addresses and cost centers belong to the company, so the lists are reloaded for it.
|
||||
if (options && canConnect) void loadOptions(next);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setShowProblems(true);
|
||||
setSaveError(null);
|
||||
if (hasProblems(validateConfigForm(form, secretStored, test))) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const cfg = await api.erpnextSaveConfig({ ...form, ...(testUnsaved && test ? { lastDetectResult: test } : {}) });
|
||||
setSaved(cfg);
|
||||
setForm(toInput(cfg));
|
||||
setReplacing(false);
|
||||
setTest(cfg.lastDetectResult ?? test);
|
||||
setTestUnsaved(false);
|
||||
setShowProblems(false);
|
||||
toast.success("ERPNext settings saved");
|
||||
} catch (e) {
|
||||
const text = String(e);
|
||||
setSaveError(text);
|
||||
toast.error("Could not save the ERPNext settings", text);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const readPem = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
set("extraCaPem", (await file.text()).trim());
|
||||
} catch (e) {
|
||||
toast.error("Could not read the certificate file", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const secretInvalid = problems.apiSecret ?? (saveError && /secret again/i.test(saveError) ? saveError : undefined);
|
||||
|
||||
return (
|
||||
<div className="voiced-stack voiced-erp">
|
||||
<div>
|
||||
<h2 className="voiced-detail__h2">ERPNext</h2>
|
||||
<p>
|
||||
Send issued invoices and payments to your ERPNext site as Sales Invoices. Everything is sent from this computer; ERPNext is never
|
||||
needed to issue an invoice.
|
||||
</p>
|
||||
<p className="voiced-muted">
|
||||
Create a dedicated ERPNext user for Voiced with only these permissions: create and submit Sales Invoice, read on the master data
|
||||
(Customer, Item, Address, Account, Company), and write on File. Use that user’s API key and secret below. An address must be
|
||||
https://; plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test hosts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Section title="Connection">
|
||||
<TextInput
|
||||
id="erp-baseUrl"
|
||||
labelText="Site address"
|
||||
placeholder="https://erp.example.com"
|
||||
value={form.baseUrl}
|
||||
invalid={Boolean(problems.baseUrl)}
|
||||
invalidText={problems.baseUrl}
|
||||
onChange={(e) => set("baseUrl", e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
id="erp-apiKey"
|
||||
labelText="API key"
|
||||
autoComplete="off"
|
||||
value={form.apiKey}
|
||||
invalid={Boolean(problems.apiKey)}
|
||||
invalidText={problems.apiKey}
|
||||
onChange={(e) => set("apiKey", e.target.value)}
|
||||
/>
|
||||
{secretShown ? (
|
||||
<div className="voiced-erp__secret">
|
||||
<p className="voiced-erp__label" id="erp-secret-label">
|
||||
API secret
|
||||
</p>
|
||||
<p aria-labelledby="erp-secret-label">
|
||||
<Tag type="green" size="sm">
|
||||
set
|
||||
</Tag>{" "}
|
||||
<span className="voiced-muted">The secret is stored on this computer and is never shown again.</span>
|
||||
</p>
|
||||
<div className="voiced-header-action">
|
||||
<Button kind="tertiary" size="sm" onClick={() => setReplacing(true)}>
|
||||
Replace
|
||||
</Button>
|
||||
<Button kind="ghost" size="sm" onClick={() => set("clearSecret", true)}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="erp-apiSecret"
|
||||
labelText="API secret"
|
||||
autoComplete="new-password"
|
||||
value={form.apiSecret}
|
||||
invalid={Boolean(secretInvalid)}
|
||||
invalidText={secretInvalid}
|
||||
helperText={form.clearSecret ? "The stored secret is removed when you save, unless you type a new one." : undefined}
|
||||
onChange={(e) => set("apiSecret", e.target.value)}
|
||||
/>
|
||||
{secretStored ? (
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReplacing(false);
|
||||
setForm((f) => (f ? { ...f, apiSecret: "", clearSecret: false } : f));
|
||||
}}
|
||||
>
|
||||
Keep the stored secret
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<TextArea
|
||||
id="erp-extraCa"
|
||||
labelText="Extra CA certificate (optional, PEM)"
|
||||
helperText="Only for a self-hosted site whose certificate is not trusted by this computer."
|
||||
rows={3}
|
||||
value={form.extraCaPem}
|
||||
onChange={(e) => set("extraCaPem", e.target.value)}
|
||||
/>
|
||||
<FileUploaderButton
|
||||
labelText="Load from file…"
|
||||
buttonKind="ghost"
|
||||
size="sm"
|
||||
accept={[".pem", ".crt", ".cer"]}
|
||||
disableLabelChanges
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
void readPem(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="voiced-header-action">
|
||||
<Button kind="secondary" size="md" onClick={() => void runTest()} disabled={testing || loadingOptions}>
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
</Button>
|
||||
{testing || loadingOptions ? <InlineLoading description={testing ? "Contacting ERPNext" : "Loading lists"} /> : null}
|
||||
{!canConnect && showProblems ? <span className="voiced-muted">Fix the address, key and secret first.</span> : null}
|
||||
</div>
|
||||
{testError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Connection failed" subtitle={testError} /> : null}
|
||||
{test ? (
|
||||
<Tile className="voiced-erp__result" aria-label="Connection test result">
|
||||
<p>
|
||||
<strong>Connected as {test.user || "an unknown user"}</strong>{" "}
|
||||
<Tag type={test.indiaCompliance ? "green" : "warm-gray"} size="sm">
|
||||
{test.indiaCompliance ? "India Compliance detected" : "India Compliance not detected"}
|
||||
</Tag>
|
||||
</p>
|
||||
<p className="voiced-muted">{versionsLine(test)}</p>
|
||||
{testUnsaved ? (
|
||||
<InlineNotification
|
||||
kind="info"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="Save to keep this result"
|
||||
subtitle="Sending invoices needs a saved connection test."
|
||||
/>
|
||||
) : (
|
||||
<p className="voiced-muted">Last saved test result.</p>
|
||||
)}
|
||||
{sortWarnings(test.warnings).map((w, i) => {
|
||||
const severity = warningSeverity(w.code);
|
||||
return (
|
||||
<InlineNotification
|
||||
key={`${w.code}-${i}`}
|
||||
kind={severity}
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={SEVERITY_TITLE[severity]}
|
||||
subtitle={w.message}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{test.warnings.length === 0 ? <p className="voiced-muted">No problems found with the current settings.</p> : null}
|
||||
<p className="voiced-muted">Test again after changing the mappings below to refresh these warnings.</p>
|
||||
</Tile>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Company and accounts"
|
||||
note={options ? "Choose from what your ERPNext site has." : "Run Test connection to choose from your site's lists; until then, type the exact names."}
|
||||
>
|
||||
{options && options.errors.length > 0 ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="Some lists could not be loaded"
|
||||
subtitle={options.errors.map((e) => `${OPTION_LIST_LABELS[e.list] ?? e.list}: ${e.message}`).join(" ")}
|
||||
/>
|
||||
) : null}
|
||||
<PickField
|
||||
id="erp-company"
|
||||
label="Company"
|
||||
value={form.company}
|
||||
onChange={changeCompany}
|
||||
items={opts.companies}
|
||||
error={optErrors.get("companies")}
|
||||
/>
|
||||
<PickField
|
||||
id="erp-companyAddress"
|
||||
label="Company address"
|
||||
value={form.companyAddress}
|
||||
onChange={(v) => set("companyAddress", v)}
|
||||
items={opts.companyAddresses}
|
||||
error={optErrors.get("companyAddresses")}
|
||||
disabledReason={companyReason}
|
||||
helper="Required by India Compliance on every invoice."
|
||||
/>
|
||||
<PickField
|
||||
id="erp-incomeAccount"
|
||||
label="Income account"
|
||||
value={form.incomeAccount}
|
||||
onChange={(v) => set("incomeAccount", v)}
|
||||
items={opts.incomeAccounts}
|
||||
error={optErrors.get("incomeAccounts")}
|
||||
disabledReason={companyReason}
|
||||
/>
|
||||
<PickField
|
||||
id="erp-costCenter"
|
||||
label="Cost center"
|
||||
value={form.costCenter}
|
||||
onChange={(v) => set("costCenter", v)}
|
||||
items={opts.costCenters}
|
||||
error={optErrors.get("costCenters")}
|
||||
disabledReason={companyReason}
|
||||
/>
|
||||
{(
|
||||
[
|
||||
["cgstAccount", "CGST account"],
|
||||
["sgstAccount", "SGST account"],
|
||||
["utgstAccount", "UTGST account"],
|
||||
["igstAccount", "IGST account"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<PickField
|
||||
key={key}
|
||||
id={`erp-${key}`}
|
||||
label={label}
|
||||
value={form[key]}
|
||||
onChange={(v) => set(key, v)}
|
||||
items={opts.taxAccounts}
|
||||
error={optErrors.get("taxAccounts")}
|
||||
disabledReason={companyReason}
|
||||
helper={key === "utgstAccount" ? "Only needed when the supplier is in a union territory without a legislature." : "Must be the account set in GST Settings."}
|
||||
/>
|
||||
))}
|
||||
<TextInput
|
||||
id="erp-paymentBank"
|
||||
labelText="Payment bank account"
|
||||
helperText="The bank or cash account that receives payments, e.g. HDFC Bank - AC. Needed to send payments."
|
||||
value={form.paymentBankAccount}
|
||||
onChange={(e) => set("paymentBankAccount", e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
id="erp-tdsAccount"
|
||||
labelText="TDS account"
|
||||
helperText="The account that holds tax deducted by clients. Needed only for payments with TDS."
|
||||
value={form.tdsAccount}
|
||||
onChange={(e) => set("tdsAccount", e.target.value)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Customers and items">
|
||||
<TextInput
|
||||
id="erp-defaultItem"
|
||||
labelText="Default item code (optional)"
|
||||
helperText="Used for invoice lines without an item code of their own; a line with none is sent as a description-only row."
|
||||
value={form.defaultItemCode}
|
||||
onChange={(e) => set("defaultItemCode", e.target.value)}
|
||||
/>
|
||||
<PickField
|
||||
id="erp-customerGroup"
|
||||
label="Customer group for new customers"
|
||||
value={form.customerGroup}
|
||||
onChange={(v) => set("customerGroup", v)}
|
||||
items={opts.customerGroups}
|
||||
error={optErrors.get("customerGroups")}
|
||||
/>
|
||||
<PickField
|
||||
id="erp-territory"
|
||||
label="Territory for new customers"
|
||||
value={form.territory}
|
||||
onChange={(v) => set("territory", v)}
|
||||
items={opts.territories}
|
||||
error={optErrors.get("territories")}
|
||||
/>
|
||||
<PickField
|
||||
id="erp-priceList"
|
||||
label="Selling price list"
|
||||
value={form.sellingPriceList}
|
||||
onChange={(v) => set("sellingPriceList", v)}
|
||||
items={opts.priceLists}
|
||||
error={optErrors.get("priceLists")}
|
||||
/>
|
||||
<fieldset className="voiced-erp__uoms">
|
||||
<legend>Units of measure</legend>
|
||||
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.</p>
|
||||
{UOM_UNITS.map((unit) => (
|
||||
<PickField
|
||||
key={unit}
|
||||
id={`erp-uom-${unit}`}
|
||||
label={`Voiced ${unit}`}
|
||||
value={form.uomMap[unit] ?? ""}
|
||||
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
|
||||
items={opts.uoms}
|
||||
error={optErrors.get("uoms")}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
</Section>
|
||||
|
||||
<Section title="Invoice numbers">
|
||||
<RadioButtonGroup
|
||||
legendText="Numbering in ERPNext"
|
||||
name="erp-namingMode"
|
||||
orientation="vertical"
|
||||
valueSelected={form.namingMode}
|
||||
onChange={(value) => set("namingMode", value === "series" ? "series" : "mirror")}
|
||||
>
|
||||
<RadioButton
|
||||
id="erp-nm-mirror"
|
||||
value="mirror"
|
||||
labelText="Mirror the Voiced number (recommended)"
|
||||
disabled={!mirrorOk}
|
||||
/>
|
||||
<RadioButton id="erp-nm-series" value="series" labelText="Use an ERPNext naming series" />
|
||||
</RadioButtonGroup>
|
||||
<p className="voiced-muted">
|
||||
{mirrorOk
|
||||
? "Mirror keeps the number printed on the PDF as the ERPNext invoice name, so GST returns match. Needs Frappe 15.73 or later."
|
||||
: "Mirror is unavailable: this ERPNext version replaces a posted invoice number. The Voiced number is stored in the remarks instead."}
|
||||
</p>
|
||||
{problems.namingMode ? <p className="voiced-erp__error">{problems.namingMode}</p> : null}
|
||||
<PickField
|
||||
id="erp-namingSeries"
|
||||
label="Naming series"
|
||||
value={form.namingSeries}
|
||||
onChange={(v) => set("namingSeries", v)}
|
||||
items={opts.namingSeries}
|
||||
error={problems.namingSeries ?? optErrors.get("namingSeries")}
|
||||
disabledReason={seriesMode ? undefined : "Only used with an ERPNext naming series."}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Sending">
|
||||
<Toggle
|
||||
id="erp-submit"
|
||||
labelText="Submit invoices when sending"
|
||||
labelA="Off: drafts for review"
|
||||
labelB="On: submitted"
|
||||
toggled={form.submitOnPush}
|
||||
onToggle={(v) => set("submitOnPush", v)}
|
||||
/>
|
||||
<Toggle
|
||||
id="erp-attach"
|
||||
labelText="Attach the PDF"
|
||||
labelA="Off"
|
||||
labelB="On"
|
||||
toggled={form.attachPdf}
|
||||
onToggle={(v) => set("attachPdf", v)}
|
||||
/>
|
||||
<Toggle
|
||||
id="erp-auto"
|
||||
labelText="Send automatically when an invoice is issued"
|
||||
labelA="Off"
|
||||
labelB="On"
|
||||
toggled={form.autoPushOnIssue}
|
||||
onToggle={(v) => set("autoPushOnIssue", v)}
|
||||
/>
|
||||
<Toggle
|
||||
id="erp-customers"
|
||||
labelText="Create customers that are missing in ERPNext"
|
||||
labelA="Off"
|
||||
labelB="On"
|
||||
toggled={form.createMissingCustomers}
|
||||
onToggle={(v) => set("createMissingCustomers", v)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{saveError && !secretInvalid ? <InlineNotification kind="error" lowContrast hideCloseButton title="Not saved" subtitle={saveError} /> : null}
|
||||
<div>
|
||||
<Button renderIcon={Save} onClick={() => void save()} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save ERPNext settings"}
|
||||
</Button>
|
||||
<p className="voiced-muted">This tab has its own Save button; Save settings at the bottom of the page does not cover it.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
Grid,
|
||||
InlineLoading,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from "@carbon/react";
|
||||
import { Add, Edit, TrashCan } from "@carbon/icons-react";
|
||||
import { api } from "../lib/api";
|
||||
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types";
|
||||
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
|
||||
import { formatAmount } from "../lib/format";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
/** Saved line items: description, HSN/SAC, type, unit and amount (kept as integer paise). */
|
||||
export default function ItemPresetsPanel({ active }: { active: boolean }) {
|
||||
const toast = useToast();
|
||||
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<ItemPreset | null>(null);
|
||||
const [deleting, setDeleting] = useState<ItemPreset | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [touched, setTouched] = useState(false);
|
||||
const launcher = useRef<HTMLElement | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setPresets(await api.listItemPresets());
|
||||
} catch (e) {
|
||||
toast.error("Could not load item presets", String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void load();
|
||||
}, [active, load]);
|
||||
|
||||
const openEditor = (preset: ItemPreset, trigger: HTMLElement) => {
|
||||
launcher.current = trigger;
|
||||
setTouched(false);
|
||||
setEditing(preset);
|
||||
};
|
||||
|
||||
const set = <K extends keyof ItemPreset>(key: K, value: ItemPreset[K]) =>
|
||||
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
|
||||
const save = async () => {
|
||||
if (!editing) return;
|
||||
setTouched(true);
|
||||
if (!editing.description.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.saveItemPreset({ ...editing, description: editing.description.trim() });
|
||||
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error("Could not save preset", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!deleting || deleting.id === null) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteItemPreset(deleting.id);
|
||||
toast.success("Preset deleted", deleting.description);
|
||||
setDeleting(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error("Could not delete preset", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <InlineLoading description="Loading item presets" />;
|
||||
|
||||
return (
|
||||
<div className="voiced-stack">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p className="voiced-muted">Saved line items you can add to an invoice in one click.</p>
|
||||
<Button
|
||||
kind="tertiary"
|
||||
size="sm"
|
||||
renderIcon={Add}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(blankPreset(), e.currentTarget)}
|
||||
>
|
||||
Add preset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{presets.length === 0 ? (
|
||||
<div className="voiced-empty">No item presets yet.</div>
|
||||
) : (
|
||||
<Table aria-label="Item presets">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Description</TableHeader>
|
||||
<TableHeader>HSN/SAC</TableHeader>
|
||||
<TableHeader>Type</TableHeader>
|
||||
<TableHeader>Amount</TableHeader>
|
||||
<TableHeader>Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{presets.map((p) => (
|
||||
<TableRow key={p.id ?? p.description}>
|
||||
<TableCell>{p.description}</TableCell>
|
||||
<TableCell>{p.hsnSac || "—"}</TableCell>
|
||||
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell>
|
||||
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Edit}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor({ ...p }, e.currentTarget)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={TrashCan}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
||||
launcher.current = e.currentTarget;
|
||||
setDeleting(p);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={editing !== null}
|
||||
modalHeading={editing?.id ? "Edit item preset" : "New item preset"}
|
||||
primaryButtonText={busy ? "Saving…" : "Save preset"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={() => setEditing(null)}
|
||||
onRequestSubmit={() => void save()}
|
||||
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
|
||||
size="sm"
|
||||
>
|
||||
{editing ? (
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextInput
|
||||
id="ip-desc"
|
||||
labelText="Description"
|
||||
value={editing.description}
|
||||
onChange={(e) => set("description", e.target.value)}
|
||||
invalid={touched && !editing.description.trim()}
|
||||
invalidText="Description is required"
|
||||
data-modal-primary-focus
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="ip-hsn"
|
||||
labelText="HSN/SAC (optional)"
|
||||
value={editing.hsnSac}
|
||||
onChange={(e) => set("hsnSac", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<Select
|
||||
id="ip-mode"
|
||||
labelText="Type"
|
||||
value={editing.mode}
|
||||
onChange={(e) => set("mode", e.target.value as LineMode)}
|
||||
>
|
||||
<SelectItem value="fixed" text="Fixed amount" />
|
||||
<SelectItem value="rate" text="Rate × quantity" />
|
||||
</Select>
|
||||
</Column>
|
||||
{editing.mode === "rate" ? (
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<Select
|
||||
id="ip-unit"
|
||||
labelText="Unit"
|
||||
value={editing.unit}
|
||||
onChange={(e) => set("unit", e.target.value as LineUnit)}
|
||||
>
|
||||
{Object.entries(UNIT_LABELS).map(([value, text]) => (
|
||||
<SelectItem key={value} value={value} text={text} />
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
) : null}
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<NumberInput
|
||||
id="ip-rate"
|
||||
label={editing.mode === "rate" ? "Rate (₹)" : "Amount (₹)"}
|
||||
min={0}
|
||||
step={1}
|
||||
value={paiseToRupees(editing.ratePaise)}
|
||||
onChange={(_e, { value }) => set("ratePaise", Math.max(0, rupeesToPaise(Number(value))))}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
danger
|
||||
open={deleting !== null}
|
||||
modalHeading="Delete item preset"
|
||||
primaryButtonText={busy ? "Deleting…" : "Delete"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={() => setDeleting(null)}
|
||||
onRequestSubmit={() => void remove()}
|
||||
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
|
||||
size="xs"
|
||||
>
|
||||
<p>
|
||||
Delete the preset “{deleting?.description}”? Invoices already created from it are not affected.
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { InlineNotification, Modal, Select, SelectItem, TextInput } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import { todayIst } from "../lib/fiscal";
|
||||
import { formatPaise, paiseToInput, parseRupeesToPaise } from "../lib/money";
|
||||
import { PAYMENT_MODES, type Payment, type PaymentMode } from "../lib/payments";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
invoiceId: number;
|
||||
invoiceNumber: string;
|
||||
/** What is still owed, in paise. */
|
||||
balancePaise: number;
|
||||
onClose: () => void;
|
||||
onRecorded: (payment: Payment) => void;
|
||||
}
|
||||
|
||||
/** "Record payment": cash received plus TDS deducted, validated against the balance before it is sent. */
|
||||
export default function RecordPaymentModal({ open, invoiceId, invoiceNumber, balancePaise, onClose, onRecorded }: Props) {
|
||||
const [paidOn, setPaidOn] = useState(todayIst());
|
||||
const [amount, setAmount] = useState("");
|
||||
const [tds, setTds] = useState("");
|
||||
const [mode, setMode] = useState<PaymentMode>("bank_transfer");
|
||||
const [reference, setReference] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
useReturnFocus(open);
|
||||
|
||||
// Each opening starts from the balance, so the common case (paid in full) is one click.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPaidOn(todayIst());
|
||||
setAmount(paiseToInput(balancePaise));
|
||||
setTds("");
|
||||
setMode("bank_transfer");
|
||||
setReference("");
|
||||
setNotes("");
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
}, [open, balancePaise]);
|
||||
|
||||
const amountPaise = parseRupeesToPaise(amount);
|
||||
const tdsPaise = parseRupeesToPaise(tds);
|
||||
const amountInvalid = amountPaise === null;
|
||||
const tdsInvalid = tdsPaise === null;
|
||||
const total = (amountPaise ?? 0) + (tdsPaise ?? 0);
|
||||
const overBalance = !amountInvalid && !tdsInvalid && total > balancePaise;
|
||||
const empty = !amountInvalid && !tdsInvalid && total === 0;
|
||||
const dateInvalid = !/^\d{4}-\d{2}-\d{2}$/.test(paidOn);
|
||||
const canSubmit = !busy && !amountInvalid && !tdsInvalid && !overBalance && !empty && !dateInvalid;
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || amountPaise === null || tdsPaise === null) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payment = await api.recordPayment({ invoiceId, paidOn, amountPaise, tdsPaise, mode, reference, notes });
|
||||
onRecorded(payment);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
modalHeading={`Record payment for ${invoiceNumber}`}
|
||||
primaryButtonText={busy ? "Saving…" : "Record payment"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={!canSubmit}
|
||||
onRequestClose={onClose}
|
||||
onRequestSubmit={() => void submit()}
|
||||
>
|
||||
<div className="voiced-stack">
|
||||
<p>
|
||||
Balance due: <strong>{formatPaise(balancePaise)}</strong>
|
||||
</p>
|
||||
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Could not record the payment" subtitle={error} /> : null}
|
||||
<TextInput
|
||||
id="payDate"
|
||||
type="date"
|
||||
labelText="Date received"
|
||||
value={paidOn}
|
||||
invalid={dateInvalid}
|
||||
invalidText="Enter a date"
|
||||
onChange={(e) => setPaidOn(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
id="payAmount"
|
||||
inputMode="decimal"
|
||||
labelText="Amount received (₹)"
|
||||
helperText="Cash that reached your account"
|
||||
value={amount}
|
||||
invalid={amountInvalid || overBalance || empty}
|
||||
invalidText={
|
||||
amountInvalid
|
||||
? "Enter an amount like 1250.50"
|
||||
: overBalance
|
||||
? `Amount plus TDS is more than the balance of ${formatPaise(balancePaise)}`
|
||||
: "Enter an amount received or TDS deducted"
|
||||
}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
id="payTds"
|
||||
inputMode="decimal"
|
||||
labelText="TDS deducted by client (₹)"
|
||||
helperText="Counts towards settling the invoice; leave empty if none"
|
||||
value={tds}
|
||||
invalid={tdsInvalid}
|
||||
invalidText="Enter an amount like 125.00"
|
||||
onChange={(e) => setTds(e.target.value)}
|
||||
/>
|
||||
<Select id="payMode" labelText="Mode" value={mode} onChange={(e) => setMode(e.target.value as PaymentMode)}>
|
||||
{PAYMENT_MODES.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id} text={m.label} />
|
||||
))}
|
||||
</Select>
|
||||
<TextInput id="payRef" labelText="UTR / cheque number (optional)" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
<TextInput id="payNotes" labelText="Notes (optional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, InlineLoading, Modal, RadioButton, RadioButtonGroup, Tag } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import type { ErpnextPushResult } from "../lib/erpnext";
|
||||
import { describePush, failedPush, summarizeBatch } from "../lib/erpnextUi";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
|
||||
export interface SendTarget {
|
||||
id: number;
|
||||
number: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
invoices: SendTarget[];
|
||||
/** Cancelled or draft invoices that were left out of the selection. */
|
||||
skipped?: number;
|
||||
/** The "submit on push" setting; the user still chooses explicitly. */
|
||||
defaultSubmit: boolean;
|
||||
onClose: () => void;
|
||||
/** Called once when a send has finished, so the list can refresh its statuses. */
|
||||
onFinished: (results: ErpnextPushResult[]) => void;
|
||||
}
|
||||
|
||||
type Phase = "confirm" | "running" | "done";
|
||||
|
||||
const TAG = { success: "green", info: "blue", warning: "warm-gray", error: "red" } as const;
|
||||
|
||||
/** Confirm step (draft or submit), then a sequential send with progress, then per-invoice results. A failure never stops the rest. */
|
||||
export default function SendToErpnextModal({ open, invoices, skipped = 0, defaultSubmit, onClose, onFinished }: Props) {
|
||||
const [submit, setSubmit] = useState(defaultSubmit);
|
||||
const [phase, setPhase] = useState<Phase>("confirm");
|
||||
const [results, setResults] = useState<ErpnextPushResult[]>([]);
|
||||
const alive = useRef(true);
|
||||
useReturnFocus(open);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSubmit(defaultSubmit);
|
||||
setPhase("confirm");
|
||||
setResults([]);
|
||||
}, [open, defaultSubmit]);
|
||||
|
||||
const run = async () => {
|
||||
setPhase("running");
|
||||
const out: ErpnextPushResult[] = [];
|
||||
for (const inv of invoices) {
|
||||
let result: ErpnextPushResult;
|
||||
try {
|
||||
result = await api.erpnextPushInvoice(inv.id, submit);
|
||||
} catch (e) {
|
||||
// Not set up, or the command itself failed: this row fails, the others still go.
|
||||
result = failedPush(inv.id, inv.number, e);
|
||||
}
|
||||
out.push(result);
|
||||
if (alive.current) setResults([...out]);
|
||||
}
|
||||
if (alive.current) setPhase("done");
|
||||
onFinished(out);
|
||||
};
|
||||
|
||||
const count = invoices.length;
|
||||
const noun = count === 1 ? "invoice" : "invoices";
|
||||
|
||||
if (phase === "confirm") {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
modalHeading={count === 1 ? `Send ${invoices[0]?.number} to ERPNext` : `Send ${count} invoices to ERPNext`}
|
||||
primaryButtonText={`Send ${count} ${noun}`}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={count === 0}
|
||||
onRequestClose={onClose}
|
||||
onRequestSubmit={() => void run()}
|
||||
>
|
||||
<div className="voiced-stack">
|
||||
<RadioButtonGroup legendText="What should ERPNext do with it?" name="erp-send-mode" orientation="vertical" valueSelected={submit ? "submit" : "draft"} onChange={(v) => setSubmit(v === "submit")}>
|
||||
<RadioButton id="erp-send-draft" value="draft" labelText="Save as a draft for review" />
|
||||
<RadioButton id="erp-send-submit" value="submit" labelText="Submit it (posts to the books)" />
|
||||
</RadioButtonGroup>
|
||||
<p className="voiced-muted">
|
||||
An invoice that is already in ERPNext is never overwritten or created twice.
|
||||
{skipped > 0 ? ` ${skipped} cancelled ${skipped === 1 ? "invoice is" : "invoices are"} left out: cancelled invoices are never sent.` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = phase === "done" ? summarizeBatch(results) : null;
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
passiveModal
|
||||
modalHeading={phase === "running" ? "Sending to ERPNext" : "Send finished"}
|
||||
preventCloseOnClickOutside
|
||||
onRequestClose={() => {
|
||||
if (phase === "done") onClose();
|
||||
}}
|
||||
>
|
||||
<div className="voiced-stack">
|
||||
{phase === "running" ? (
|
||||
<InlineLoading description={`Sent ${results.length} of ${count}…`} />
|
||||
) : (
|
||||
<p role="status">
|
||||
<strong>{summary?.title}</strong>
|
||||
</p>
|
||||
)}
|
||||
<ul className="voiced-erp__results" aria-label="Results">
|
||||
{results.map((r) => {
|
||||
const note = describePush(r);
|
||||
return (
|
||||
<li key={r.invoiceId}>
|
||||
<Tag type={TAG[note.kind]} size="sm">
|
||||
{r.ok ? (r.noOp ? "Up to date" : r.remoteDocstatus === 1 ? "Submitted" : "Draft") : r.status === "conflict" ? "Conflict" : r.status === "refused" ? "Not sent" : "Error"}
|
||||
</Tag>{" "}
|
||||
<strong>{r.number || `#${r.invoiceId}`}</strong>
|
||||
{r.remoteName && r.ok ? <span className="voiced-muted"> {r.remoteName}</span> : null}
|
||||
{!r.ok && r.error ? <div className="voiced-muted">{r.error}</div> : null}
|
||||
{r.ok && r.warnings.length > 0 ? <div className="voiced-muted">{r.warnings.join(" ")}</div> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{phase === "done" ? (
|
||||
<Button kind="primary" size="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createContext, useContext, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { ToastNotification } from "@carbon/react";
|
||||
import { createToastStore, type ToastStore } from "../lib/toastStore";
|
||||
|
||||
export interface ToastApi {
|
||||
success: (title: string, subtitle?: string) => number;
|
||||
info: (title: string, subtitle?: string) => number;
|
||||
warning: (title: string, subtitle?: string) => number;
|
||||
/** Errors stay until the user closes them. */
|
||||
error: (title: string, subtitle?: string) => number;
|
||||
dismiss: (id: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
function apiFor(store: ToastStore): ToastApi {
|
||||
return {
|
||||
success: (t, s) => store.add("success", t, s),
|
||||
info: (t, s) => store.add("info", t, s),
|
||||
warning: (t, s) => store.add("warning", t, s),
|
||||
error: (t, s) => store.add("error", t, s),
|
||||
dismiss: store.dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stacked top-right toasts. Place it inside the Carbon <Theme> so the tokens apply. */
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const storeRef = useRef<ToastStore | null>(null);
|
||||
if (!storeRef.current) storeRef.current = createToastStore();
|
||||
const store = storeRef.current;
|
||||
const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||
const api = useMemo(() => apiFor(store), [store]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
{/* The live region stays mounted so screen readers announce toasts as they are added. */}
|
||||
<div className="voiced-toasts" aria-live="polite" aria-relevant="additions" role="region" aria-label="Notifications">
|
||||
{toasts.map((t) => (
|
||||
<ToastNotification
|
||||
key={t.id}
|
||||
kind={t.kind}
|
||||
lowContrast
|
||||
title={t.title}
|
||||
subtitle={t.subtitle}
|
||||
role={t.kind === "error" ? "alert" : "status"}
|
||||
onClose={() => {
|
||||
store.dismiss(t.id);
|
||||
return false;
|
||||
}}
|
||||
onCloseButtonClick={() => store.dismiss(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const api = useContext(ToastContext);
|
||||
if (!api) throw new Error("useToast must be used inside <ToastProvider>");
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import { exportInvoicesV1, type InvoiceExportFormat } from "../lib/invoiceExportV1";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
|
||||
/** "Export as voiced.invoice.v1": JSON or line-item CSV of the given invoices, through the save dialog. */
|
||||
export function useGenericExport(): { busy: boolean; run: (ids: number[], format: InvoiceExportFormat) => Promise<void> } {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const run = useCallback(
|
||||
async (ids: number[], format: InvoiceExportFormat) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const done = await exportInvoicesV1(
|
||||
{
|
||||
getInvoice: api.getInvoice,
|
||||
listPayments: api.listPayments,
|
||||
listClients: api.listClients,
|
||||
getLastExportDir: api.getLastExportDir,
|
||||
save: ({ defaultPath, format: fmt }) =>
|
||||
save({ defaultPath, filters: [fmt === "csv" ? { name: "CSV (line items)", extensions: ["csv"] } : { name: "JSON (voiced.invoice.v1)", extensions: ["json"] }] }),
|
||||
writeExportFile: api.writeExportFile,
|
||||
now: () => new Date(),
|
||||
},
|
||||
ids,
|
||||
format,
|
||||
);
|
||||
if (done) toast.success(`Exported ${done.count} ${done.count === 1 ? "invoice" : "invoices"}`, done.path);
|
||||
} catch (e) {
|
||||
toast.error("Could not export the invoices", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
return { busy, run };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Gives focus back to whatever had it when a modal opened, once the modal closes. Carbon's Modal only
|
||||
* does this with a launcher ref, which does not suit modals opened from table rows or menus.
|
||||
*/
|
||||
export function useReturnFocus(open: boolean): void {
|
||||
const origin = useRef<HTMLElement | null>(null);
|
||||
const wasOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (open && !wasOpen.current) {
|
||||
origin.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
} else if (!open && wasOpen.current) {
|
||||
const el = origin.current;
|
||||
origin.current = null;
|
||||
// After the modal has unmounted its focus trap.
|
||||
if (el && el.isConnected) setTimeout(() => el.focus(), 0);
|
||||
}
|
||||
wasOpen.current = open;
|
||||
}, [open]);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
[
|
||||
{
|
||||
"schema": "voiced.invoice.v1",
|
||||
"number": "INV/2026-001",
|
||||
"issue_date": "2026-04-01",
|
||||
"due_date": "2026-05-01",
|
||||
"document_type": "tax_invoice",
|
||||
"status": "issued",
|
||||
"cancelled": false,
|
||||
"cancelled_at": null,
|
||||
"cancel_reason": null,
|
||||
"currency": "INR",
|
||||
"po_number": "PO-9",
|
||||
"supplier": {
|
||||
"name": "Test Vendor",
|
||||
"address": "1 Main Rd\nPune",
|
||||
"gstin": "27AAPFU0939F1ZV",
|
||||
"pan": "AAPFU0939F",
|
||||
"gst_registration": "regular",
|
||||
"state": {
|
||||
"code": "27",
|
||||
"name": "Maharashtra"
|
||||
}
|
||||
},
|
||||
"client": {
|
||||
"name": "Client Ltd",
|
||||
"gstin": "29AABCU9603R1ZJ",
|
||||
"gst_category": "registered_regular",
|
||||
"address": {
|
||||
"text": "12 MG Road\nBengaluru 560001",
|
||||
"line1": "12 MG Road",
|
||||
"line2": null,
|
||||
"city": "Bengaluru",
|
||||
"pincode": "560001",
|
||||
"state": {
|
||||
"code": "29",
|
||||
"name": "Karnataka"
|
||||
}
|
||||
},
|
||||
"place_of_supply": {
|
||||
"code": "29",
|
||||
"name": "Karnataka"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"line": 1,
|
||||
"description": "Design",
|
||||
"hsn_sac": "998391",
|
||||
"quantity": "1",
|
||||
"unit": null,
|
||||
"rate_paise": 500010,
|
||||
"rate": "5000.10",
|
||||
"taxable_value_paise": 500010,
|
||||
"taxable_value": "5000.10"
|
||||
},
|
||||
{
|
||||
"line": 2,
|
||||
"description": "Consulting",
|
||||
"hsn_sac": null,
|
||||
"quantity": "2",
|
||||
"unit": "hour",
|
||||
"rate_paise": 115505,
|
||||
"rate": "1155.05",
|
||||
"taxable_value_paise": 231000,
|
||||
"taxable_value": "2310.00"
|
||||
}
|
||||
],
|
||||
"tax": {
|
||||
"cgst": {
|
||||
"rate": "0",
|
||||
"amount_paise": 0,
|
||||
"amount": "0.00"
|
||||
},
|
||||
"sgst": {
|
||||
"rate": "0",
|
||||
"amount_paise": 0,
|
||||
"amount": "0.00"
|
||||
},
|
||||
"utgst": {
|
||||
"rate": "0",
|
||||
"amount_paise": 0,
|
||||
"amount": "0.00"
|
||||
},
|
||||
"igst": {
|
||||
"rate": "18",
|
||||
"amount_paise": 131400,
|
||||
"amount": "1314.00"
|
||||
}
|
||||
},
|
||||
"totals": {
|
||||
"subtotal_paise": 731010,
|
||||
"subtotal": "7310.10",
|
||||
"discount_paise": 1010,
|
||||
"discount": "10.10",
|
||||
"taxable_value_paise": 730000,
|
||||
"taxable_value": "7300.00",
|
||||
"tax_paise": 131400,
|
||||
"tax": "1314.00",
|
||||
"total_paise": 861400,
|
||||
"total": "8614.00",
|
||||
"amount_in_words": "Rupees Eight Thousand Six Hundred Fourteen Only"
|
||||
},
|
||||
"reverse_charge": false,
|
||||
"notes": "Thank you",
|
||||
"payments": [
|
||||
{
|
||||
"date": "2026-04-10",
|
||||
"amount_paise": 700000,
|
||||
"amount": "7000.00",
|
||||
"tds_paise": 70000,
|
||||
"tds": "700.00",
|
||||
"mode": "bank_transfer",
|
||||
"reference": "UTR123"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -7,9 +7,22 @@ import type {
|
||||
InvoiceInput,
|
||||
InvoiceSeries,
|
||||
InvoiceSummary,
|
||||
ItemPreset,
|
||||
Settings,
|
||||
} from "./types";
|
||||
import type { LogoAsset } from "./logo";
|
||||
import type { Payment, PaymentInput } from "./payments";
|
||||
import type { BackupStatus, BackupSummary, PendingRestore } from "./backup";
|
||||
import type { LedgerRow } from "./historyExport";
|
||||
import type {
|
||||
ErpnextConfig,
|
||||
ErpnextConfigInput,
|
||||
ErpnextConnectionTest,
|
||||
ErpnextOptions,
|
||||
ErpnextPaymentPushResult,
|
||||
ErpnextPushResult,
|
||||
ErpnextSyncStatus,
|
||||
} from "./erpnext";
|
||||
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
||||
|
||||
export interface ArchiveStatus {
|
||||
@@ -36,14 +49,23 @@ export const api = {
|
||||
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
|
||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||
|
||||
listItemPresets: () => invoke<ItemPreset[]>("list_item_presets"),
|
||||
saveItemPreset: (preset: ItemPreset) => invoke<ItemPreset>("save_item_preset", { preset }),
|
||||
deleteItemPreset: (id: number) => invoke<void>("delete_item_preset", { id }),
|
||||
|
||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
||||
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
||||
cancelInvoice: (id: number, reason: string) =>
|
||||
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
||||
listInvoiceLedger: () => invoke<LedgerRow[]>("list_invoice_ledger"),
|
||||
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
||||
|
||||
recordPayment: (input: PaymentInput) => invoke<Payment>("record_payment", { input }),
|
||||
listPayments: (invoiceId: number) => invoke<Payment[]>("list_payments", { invoiceId }),
|
||||
deletePayment: (id: number) => invoke<void>("delete_payment", { id }),
|
||||
|
||||
saveDraft: (id: number | null, payload: unknown) =>
|
||||
invoke<number>("save_draft", { id, payload }),
|
||||
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
|
||||
@@ -92,4 +114,32 @@ export const api = {
|
||||
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
||||
openFile: (path: string) => invoke<void>("open_file", { path }),
|
||||
|
||||
// Backup and restore. A restore is staged now and applied by the next start (see commands/backup.rs).
|
||||
createBackup: (dest: string) => invoke<BackupSummary>("create_backup", { dest }),
|
||||
restoreBackup: (path: string) => invoke<PendingRestore>("restore_backup", { path }),
|
||||
cancelPendingRestore: () => invoke<void>("cancel_pending_restore"),
|
||||
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
|
||||
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
|
||||
restartApp: () => invoke<void>("restart_app"),
|
||||
|
||||
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
|
||||
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
|
||||
erpnextSaveConfig: (config: ErpnextConfigInput) => invoke<ErpnextConfig>("erpnext_save_config", { config }),
|
||||
erpnextTestConnection: (config: ErpnextConfigInput) =>
|
||||
invoke<ErpnextConnectionTest>("erpnext_test_connection", { config }),
|
||||
erpnextLoadOptions: (config: ErpnextConfigInput) => invoke<ErpnextOptions>("erpnext_load_options", { config }),
|
||||
/** `submit` omitted follows the "submit on push" setting. A failed push resolves with `ok: false`. */
|
||||
erpnextPushInvoice: (id: number, submit?: boolean) =>
|
||||
invoke<ErpnextPushResult>("erpnext_push_invoice", { id, submit: submit ?? null }),
|
||||
/** One result per distinct id, in order; a failing row never stops the rest. */
|
||||
erpnextPushInvoices: (ids: number[], submit?: boolean) =>
|
||||
invoke<ErpnextPushResult[]>("erpnext_push_invoices", { ids, submit: submit ?? null }),
|
||||
erpnextPushPayment: (paymentId: number) =>
|
||||
invoke<ErpnextPaymentPushResult>("erpnext_push_payment", { paymentId }),
|
||||
erpnextSyncStatus: (id: number) => invoke<ErpnextSyncStatus>("erpnext_sync_status", { id }),
|
||||
/** Every invoice that has a sync row (invoices never pushed are absent). */
|
||||
erpnextSyncStatuses: () => invoke<ErpnextSyncStatus[]>("erpnext_sync_statuses"),
|
||||
/** `<base>/app/sales-invoice/<name>`; rejects when the invoice was never sent. */
|
||||
erpnextOpenUrl: (id: number) => invoke<string>("erpnext_open_url", { id }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { backupFileName, ensureZipExtension, formatBytes, formatWhen } from "./backup";
|
||||
|
||||
describe("backup helpers", () => {
|
||||
it("names backups by local date and time", () => {
|
||||
expect(backupFileName(new Date(2026, 2, 4, 9, 5))).toBe("voiced-backup-20260304-0905.zip");
|
||||
});
|
||||
it("adds the zip extension only when missing", () => {
|
||||
expect(ensureZipExtension("/a/b")).toBe("/a/b.zip");
|
||||
expect(ensureZipExtension("/a/b.ZIP")).toBe("/a/b.ZIP");
|
||||
expect(ensureZipExtension("/a/b.zip")).toBe("/a/b.zip");
|
||||
});
|
||||
it("formats sizes", () => {
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
expect(formatBytes(1536)).toBe("1.5 KB");
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
|
||||
expect(formatBytes(300 * 1024 * 1024)).toBe("300 MB");
|
||||
});
|
||||
it("falls back to the raw text for an unparseable time", () => {
|
||||
expect(formatWhen("not a date")).toBe("not a date");
|
||||
expect(formatWhen("2026-03-04T10:00:00Z")).toMatch(/2026/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Shapes returned by the backup commands (src-tauri/src/commands/backup.rs). */
|
||||
export interface BackupSummary {
|
||||
path: string;
|
||||
createdAt: string;
|
||||
schemaVersion: number;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface PendingRestore {
|
||||
backupName: string;
|
||||
backupCreatedAt: string;
|
||||
appVersion: string;
|
||||
schemaVersion: number;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
stagedAt: string;
|
||||
}
|
||||
|
||||
export interface LastRestore {
|
||||
ok: boolean;
|
||||
at: string;
|
||||
backupName: string;
|
||||
message: string;
|
||||
safetyDir: string | null;
|
||||
}
|
||||
|
||||
export interface BackupStatus {
|
||||
autoBackup: boolean;
|
||||
autoDir: string;
|
||||
lastAutoBackup: string | null;
|
||||
autoBackupCount: number;
|
||||
pendingRestore: PendingRestore | null;
|
||||
lastRestore: LastRestore | null;
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** `voiced-backup-20260304-1530.zip` in local time. */
|
||||
export function backupFileName(now: Date = new Date()): string {
|
||||
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
||||
return `voiced-backup-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.zip`;
|
||||
}
|
||||
|
||||
/** The save dialog does not always add the extension (notably on Linux). */
|
||||
export function ensureZipExtension(path: string): string {
|
||||
return /\.zip$/i.test(path) ? path : `${path}.zip`;
|
||||
}
|
||||
|
||||
/** 1536 -> "1.5 KB". Sizes of backup contents are display-only, so plain division is fine. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let value = bytes / 1024;
|
||||
let i = 0;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** "4 Mar 2026, 3:30 pm" for an ISO timestamp; the raw text if it does not parse. */
|
||||
export function formatWhen(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { composeAddress, pincodeError, splitLegacyAddress } from "./clientAddress";
|
||||
|
||||
describe("composeAddress", () => {
|
||||
it("joins lines and the city/state/pincode line", () => {
|
||||
expect(
|
||||
composeAddress({ line1: "12 MG Road", line2: "Floor 2", city: "Bengaluru", stateCode: "29", pincode: "560001" }),
|
||||
).toBe("12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
||||
});
|
||||
it("skips empty parts", () => {
|
||||
expect(composeAddress({ line1: " A ", line2: "", city: "", stateCode: "", pincode: "560001" })).toBe("A\n560001");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "Pune", stateCode: "", pincode: "" })).toBe("Pune");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "29", pincode: "" })).toBe("Karnataka");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "", pincode: "" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitLegacyAddress", () => {
|
||||
it("puts the first line in line1 and the rest in line2", () => {
|
||||
expect(splitLegacyAddress("Old style\nfree text\r\nBangalore")).toEqual({
|
||||
line1: "Old style",
|
||||
line2: "free text, Bangalore",
|
||||
});
|
||||
expect(splitLegacyAddress("")).toEqual({ line1: "", line2: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pincodeError", () => {
|
||||
it("accepts empty and 6 digits only", () => {
|
||||
expect(pincodeError("")).toBeUndefined();
|
||||
expect(pincodeError("560001")).toBeUndefined();
|
||||
expect(pincodeError("56001")).toBeDefined();
|
||||
expect(pincodeError("56000A")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { INDIAN_STATES } from "./types";
|
||||
|
||||
export interface AddressParts {
|
||||
line1: string;
|
||||
line2: string;
|
||||
city: string;
|
||||
stateCode: string;
|
||||
pincode: string;
|
||||
}
|
||||
|
||||
export const EMPTY_ADDRESS: AddressParts = { line1: "", line2: "", city: "", stateCode: "", pincode: "" };
|
||||
|
||||
/**
|
||||
* The stored multi-line address: line 1, line 2, then "City, State - Pincode".
|
||||
* Mirrors `compose_address` in src-tauri/src/commands/clients.rs, which is authoritative on save.
|
||||
*/
|
||||
export function composeAddress(parts: AddressParts): string {
|
||||
const stateName = INDIAN_STATES.find((s) => s.code === parts.stateCode)?.name ?? "";
|
||||
const place = [parts.city.trim(), stateName].filter(Boolean).join(", ");
|
||||
const pin = parts.pincode.trim();
|
||||
const last = place && pin ? `${place} - ${pin}` : place || pin;
|
||||
return [parts.line1.trim(), parts.line2.trim(), last].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
/** Best effort for clients saved before structured fields existed: first line to line 1, the rest to line 2. */
|
||||
export function splitLegacyAddress(address: string): Pick<AddressParts, "line1" | "line2"> {
|
||||
const lines = address
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
return { line1: lines[0] ?? "", line2: lines.slice(1).join(", ") };
|
||||
}
|
||||
|
||||
export function isPincode(value: string): boolean {
|
||||
return /^[0-9]{6}$/.test(value.trim());
|
||||
}
|
||||
|
||||
export function pincodeError(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
return isPincode(value) ? undefined : "Pincode is 6 digits";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { invoiceToFormState } from "./duplicate";
|
||||
import { fromDraftPayload, toDraftPayload } from "./draft";
|
||||
import type { Invoice } from "./types";
|
||||
|
||||
const invoice = {
|
||||
id: 7,
|
||||
number: "INV/2025-007",
|
||||
invoiceDate: "2025-04-01",
|
||||
dueDate: "2025-05-01",
|
||||
clientId: 3,
|
||||
clientName: "Acme",
|
||||
clientAddress: "1 Road",
|
||||
clientGstin: "",
|
||||
poNumber: "PO-1",
|
||||
placeOfSupplyStateCode: "29",
|
||||
discount: 50,
|
||||
taxRate: 18,
|
||||
reverseCharge: false,
|
||||
bankAccountId: 2,
|
||||
signaturePath: "assets/sig.png",
|
||||
notes: "Thanks",
|
||||
items: [
|
||||
{ id: 11, description: "Design", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 1000, sortOrder: 4, hsnSac: "998" },
|
||||
],
|
||||
} as unknown as Invoice;
|
||||
|
||||
describe("invoiceToFormState", () => {
|
||||
it("copies the content with a fresh date, due date and no item ids", () => {
|
||||
const s = invoiceToFormState(invoice, { paymentTermsDays: 15 }, "2026-02-20");
|
||||
expect(s.invoiceDate).toBe("2026-02-20");
|
||||
expect(s.dueDate).toBe("2026-03-07");
|
||||
expect(s.clientName).toBe("Acme");
|
||||
expect(s.placeOfSupply).toBe("29");
|
||||
expect(s.bankId).toBe(2);
|
||||
expect(s.saveClient).toBe(false);
|
||||
expect(s.items).toHaveLength(1);
|
||||
expect(s.items[0]).not.toHaveProperty("id");
|
||||
expect(s.items[0].sortOrder).toBe(0);
|
||||
expect(s.items[0].hsnSac).toBe("998");
|
||||
});
|
||||
|
||||
it("survives a round trip through the draft payload", () => {
|
||||
const s = invoiceToFormState(invoice, { paymentTermsDays: 30 }, "2026-02-20");
|
||||
expect(fromDraftPayload(toDraftPayload(s), s)).toEqual(s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { addDays, todayIso } from "./format";
|
||||
import type { InvoiceFormState } from "./draft";
|
||||
import type { Invoice, Settings } from "./types";
|
||||
|
||||
/**
|
||||
* The New invoice form state for "Duplicate": every content field of the issued invoice, with today's date,
|
||||
* a fresh due date, and no link to the original (no id, no number). The result goes through the normal draft
|
||||
* mechanism (`toDraftPayload` + `saveDraft`), so the original stays immutable.
|
||||
*/
|
||||
export function invoiceToFormState(invoice: Invoice, settings: Pick<Settings, "paymentTermsDays">, today = todayIso()): InvoiceFormState {
|
||||
return {
|
||||
invoiceDate: today,
|
||||
dueDate: addDays(today, settings.paymentTermsDays),
|
||||
clientId: invoice.clientId,
|
||||
clientName: invoice.clientName,
|
||||
clientAddress: invoice.clientAddress,
|
||||
clientGstin: invoice.clientGstin,
|
||||
poNumber: invoice.poNumber,
|
||||
placeOfSupply: invoice.placeOfSupplyStateCode,
|
||||
saveClient: false,
|
||||
items: invoice.items.map((item, index) => {
|
||||
const { id: _id, ...rest } = item;
|
||||
return { ...rest, sortOrder: index };
|
||||
}),
|
||||
discount: invoice.discount,
|
||||
taxRate: invoice.taxRate,
|
||||
reverseCharge: invoice.reverseCharge,
|
||||
bankId: invoice.bankAccountId,
|
||||
signaturePath: invoice.signaturePath,
|
||||
notes: invoice.notes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/** Types for the ERPNext commands (see src-tauri/src/commands/erpnext.rs); the UI helpers live in erpnextUi.ts. */
|
||||
|
||||
export type ErpnextNamingMode = "mirror" | "series";
|
||||
|
||||
/** What the backend returns. The API secret is never included: `apiSecretSet` says whether one is stored. */
|
||||
export interface ErpnextConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
apiSecretSet: boolean;
|
||||
extraCaPem: string;
|
||||
company: string;
|
||||
companyAddress: string;
|
||||
namingMode: ErpnextNamingMode;
|
||||
namingSeries: string;
|
||||
incomeAccount: string;
|
||||
costCenter: string;
|
||||
cgstAccount: string;
|
||||
sgstAccount: string;
|
||||
utgstAccount: string;
|
||||
igstAccount: string;
|
||||
taxTemplateIntra: string;
|
||||
taxTemplateInter: string;
|
||||
paymentBankAccount: string;
|
||||
tdsAccount: string;
|
||||
defaultItemCode: string;
|
||||
/** Voiced unit (second, minute, hour, session, unit) to ERPNext UOM. */
|
||||
uomMap: Record<string, string>;
|
||||
customerGroup: string;
|
||||
territory: string;
|
||||
sellingPriceList: string;
|
||||
submitOnPush: boolean;
|
||||
attachPdf: boolean;
|
||||
autoPushOnIssue: boolean;
|
||||
createMissingCustomers: boolean;
|
||||
/** The last connection test, or null if none was stored. */
|
||||
lastDetectResult: ErpnextConnectionTest | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the webview sends to save or test. A blank `apiSecret` keeps the stored secret, a value replaces it,
|
||||
* and `clearSecret` removes it. `lastDetectResult` omitted keeps the stored one.
|
||||
*/
|
||||
export type ErpnextConfigInput = Omit<ErpnextConfig, "apiSecretSet" | "lastDetectResult"> & {
|
||||
apiSecret: string;
|
||||
clearSecret?: boolean;
|
||||
lastDetectResult?: ErpnextConnectionTest | null;
|
||||
};
|
||||
|
||||
export interface ErpnextWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ErpnextVersions {
|
||||
frappe: string | null;
|
||||
erpnext: string | null;
|
||||
indiaCompliance: string | null;
|
||||
}
|
||||
|
||||
export interface ErpnextConnectionTest {
|
||||
user: string;
|
||||
versions: ErpnextVersions;
|
||||
features: { v2Naming: boolean; indiaCompliance: boolean };
|
||||
indiaCompliance: boolean;
|
||||
warnings: ErpnextWarning[];
|
||||
}
|
||||
|
||||
export interface ErpnextOptionItem {
|
||||
/** The value to store (the document name). */
|
||||
name: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ErpnextOptions {
|
||||
companies: ErpnextOptionItem[];
|
||||
companyAddresses: ErpnextOptionItem[];
|
||||
incomeAccounts: ErpnextOptionItem[];
|
||||
taxAccounts: ErpnextOptionItem[];
|
||||
taxTemplates: ErpnextOptionItem[];
|
||||
namingSeries: ErpnextOptionItem[];
|
||||
itemGroups: ErpnextOptionItem[];
|
||||
uoms: ErpnextOptionItem[];
|
||||
costCenters: ErpnextOptionItem[];
|
||||
priceLists: ErpnextOptionItem[];
|
||||
customerGroups: ErpnextOptionItem[];
|
||||
territories: ErpnextOptionItem[];
|
||||
/** Lists that failed to load; the others are still filled. */
|
||||
errors: { list: string; message: string }[];
|
||||
}
|
||||
|
||||
export type ErpnextErrorKind =
|
||||
| "auth"
|
||||
| "permission"
|
||||
| "not_found"
|
||||
| "duplicate"
|
||||
| "validation"
|
||||
| "rate_limit"
|
||||
| "server"
|
||||
| "timeout"
|
||||
| "network"
|
||||
| "config"
|
||||
| "protocol"
|
||||
| "conflict"
|
||||
| "precondition"
|
||||
| "other";
|
||||
|
||||
/** `refused`: nothing was sent and nothing was recorded (a draft or cancelled invoice). */
|
||||
export type ErpnextPushStatus = "synced" | "error" | "conflict" | "refused";
|
||||
|
||||
export interface ErpnextPushResult {
|
||||
invoiceId: number;
|
||||
number: string;
|
||||
ok: boolean;
|
||||
status: ErpnextPushStatus;
|
||||
remoteName: string;
|
||||
/** 0 draft, 1 submitted. */
|
||||
remoteDocstatus: number;
|
||||
/** Created by this call, not found already there. */
|
||||
created: boolean;
|
||||
/** Already synced with the same payload; nothing was done. */
|
||||
noOp: boolean;
|
||||
/** The archived PDF is attached on the remote document. */
|
||||
attached: boolean;
|
||||
error: string | null;
|
||||
errorKind: ErpnextErrorKind | null;
|
||||
/** Non-fatal notes, e.g. sent without the PDF because the invoice is not archived. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ErpnextPaymentPushResult {
|
||||
paymentId: number;
|
||||
invoiceId: number;
|
||||
ok: boolean;
|
||||
entryName: string | null;
|
||||
/** Sent earlier; nothing was posted. */
|
||||
alreadySynced: boolean;
|
||||
error: string | null;
|
||||
errorKind: ErpnextErrorKind | null;
|
||||
}
|
||||
|
||||
/** `none` until something was pushed. */
|
||||
export type ErpnextSyncState = "none" | "pending" | "synced" | "error" | "conflict";
|
||||
|
||||
export interface ErpnextSyncStatus {
|
||||
invoiceId: number;
|
||||
status: ErpnextSyncState;
|
||||
remoteName: string;
|
||||
/** 0 draft, 1 submitted. */
|
||||
remoteDocstatus: number;
|
||||
lastError: string;
|
||||
syncedAt: string | null;
|
||||
attached: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { api } from "./api";
|
||||
|
||||
/** Opens the invoice's ERPNext page in the system browser (`<base>/app/sales-invoice/<name>`); rejects if it was never sent. */
|
||||
export async function openInErpnext(invoiceId: number): Promise<void> {
|
||||
await openUrl(await api.erpnextOpenUrl(invoiceId));
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ErpnextConfig, ErpnextConnectionTest, ErpnextPushResult, ErpnextSyncStatus } from "./erpnext";
|
||||
import {
|
||||
autoPushIssued,
|
||||
baseUrlProblem,
|
||||
connectionProblems,
|
||||
describePaymentPush,
|
||||
describePush,
|
||||
failedPush,
|
||||
hasDetectResult,
|
||||
isConfigured,
|
||||
paymentPushDisabledReason,
|
||||
pushDisabledReason,
|
||||
summarizeBatch,
|
||||
syncTag,
|
||||
toInput,
|
||||
validateConfigForm,
|
||||
warningSeverity,
|
||||
withDefaultUoms,
|
||||
withStoredValue,
|
||||
sortWarnings,
|
||||
sendableSelection,
|
||||
formatSyncedAt,
|
||||
} from "./erpnextUi";
|
||||
|
||||
const test: ErpnextConnectionTest = {
|
||||
user: "api@example.com",
|
||||
versions: { frappe: "15.80.0", erpnext: "15.70.0", indiaCompliance: "15.30.0" },
|
||||
features: { v2Naming: true, indiaCompliance: true },
|
||||
indiaCompliance: true,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const config = (patch: Partial<ErpnextConfig> = {}): ErpnextConfig => ({
|
||||
baseUrl: "https://erp.example.com",
|
||||
apiKey: "key",
|
||||
apiSecretSet: true,
|
||||
extraCaPem: "",
|
||||
company: "Test Co",
|
||||
companyAddress: "",
|
||||
namingMode: "mirror",
|
||||
namingSeries: "",
|
||||
incomeAccount: "",
|
||||
costCenter: "",
|
||||
cgstAccount: "",
|
||||
sgstAccount: "",
|
||||
utgstAccount: "",
|
||||
igstAccount: "",
|
||||
taxTemplateIntra: "",
|
||||
taxTemplateInter: "",
|
||||
paymentBankAccount: "",
|
||||
tdsAccount: "",
|
||||
defaultItemCode: "",
|
||||
uomMap: {},
|
||||
customerGroup: "",
|
||||
territory: "",
|
||||
sellingPriceList: "",
|
||||
submitOnPush: false,
|
||||
attachPdf: true,
|
||||
autoPushOnIssue: false,
|
||||
createMissingCustomers: true,
|
||||
lastDetectResult: test,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const sync = (patch: Partial<ErpnextSyncStatus>): ErpnextSyncStatus => ({
|
||||
invoiceId: 1,
|
||||
status: "synced",
|
||||
remoteName: "INV/2026-001",
|
||||
remoteDocstatus: 0,
|
||||
lastError: "",
|
||||
syncedAt: null,
|
||||
attached: false,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const push = (patch: Partial<ErpnextPushResult> = {}): ErpnextPushResult => ({
|
||||
invoiceId: 1,
|
||||
number: "INV/2026-001",
|
||||
ok: true,
|
||||
status: "synced",
|
||||
remoteName: "INV/2026-001",
|
||||
remoteDocstatus: 0,
|
||||
created: true,
|
||||
noOp: false,
|
||||
attached: true,
|
||||
error: null,
|
||||
errorKind: null,
|
||||
warnings: [],
|
||||
...patch,
|
||||
});
|
||||
|
||||
describe("status tags", () => {
|
||||
it("maps every sync state", () => {
|
||||
expect(syncTag(undefined)).toMatchObject({ label: "Not sent", type: "gray" });
|
||||
expect(syncTag(sync({ status: "none" })).label).toBe("Not sent");
|
||||
expect(syncTag(sync({ remoteDocstatus: 0 }))).toMatchObject({ label: "Draft in ERPNext", type: "blue" });
|
||||
expect(syncTag(sync({ remoteDocstatus: 1 }))).toMatchObject({ label: "Submitted", type: "green" });
|
||||
expect(syncTag(sync({ status: "error", lastError: "Item missing" }))).toMatchObject({ label: "Error", type: "red", tooltip: "Item missing" });
|
||||
expect(syncTag(sync({ status: "conflict", lastError: "" }))).toMatchObject({ label: "Conflict", type: "purple" });
|
||||
expect(syncTag(sync({ status: "error", lastError: "" })).tooltip).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("configured and disabled reasons", () => {
|
||||
it("needs an address, a key and a stored secret", () => {
|
||||
expect(isConfigured(config())).toBe(true);
|
||||
expect(isConfigured(config({ baseUrl: " " }))).toBe(false);
|
||||
expect(isConfigured(config({ apiKey: "" }))).toBe(false);
|
||||
expect(isConfigured(config({ apiSecretSet: false }))).toBe(false);
|
||||
expect(isConfigured(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for a connection test only when the vendor is registered", () => {
|
||||
const untested = config({ lastDetectResult: null });
|
||||
expect(pushDisabledReason({ cfg: untested, vendorRegistered: true })).toMatch(/Run Test connection first/);
|
||||
expect(pushDisabledReason({ cfg: untested, vendorRegistered: false })).toBeNull();
|
||||
expect(pushDisabledReason({ cfg: config(), vendorRegistered: true })).toBeNull();
|
||||
expect(hasDetectResult(null)).toBe(false);
|
||||
expect(hasDetectResult({} as ErpnextConnectionTest)).toBe(false);
|
||||
expect(pushDisabledReason({ cfg: null, vendorRegistered: false })).toMatch(/not set up/);
|
||||
expect(pushDisabledReason({ cfg: config(), vendorRegistered: false, invoiceStatus: "cancelled" })).toMatch(/Cancelled/);
|
||||
expect(pushDisabledReason({ cfg: config(), vendorRegistered: false, invoiceStatus: "issued" })).toBeNull();
|
||||
});
|
||||
|
||||
it("allows a payment only against a submitted invoice, once", () => {
|
||||
const cfg = config();
|
||||
expect(paymentPushDisabledReason({ cfg, sync: undefined, entry: null })).toMatch(/Send the invoice/);
|
||||
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 0 }), entry: null })).toMatch(/Submit the invoice in ERPNext first/);
|
||||
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 1 }), entry: null })).toBeNull();
|
||||
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 1 }), entry: "ACC-PAY-1" })).toMatch(/ACC-PAY-1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("push results", () => {
|
||||
it("describes outcomes", () => {
|
||||
expect(describePush(push())).toMatchObject({ kind: "success", subtitle: "INV/2026-001 (draft)" });
|
||||
expect(describePush(push({ remoteDocstatus: 1 })).subtitle).toContain("submitted");
|
||||
expect(describePush(push({ noOp: true })).kind).toBe("info");
|
||||
const warned = describePush(push({ warnings: ["The PDF was not attached: no permission"] }));
|
||||
expect(warned.kind).toBe("warning");
|
||||
expect(warned.subtitle).toContain("no permission");
|
||||
expect(describePush(push({ ok: false, status: "refused", error: "Run Test connection first", errorKind: "precondition" })).kind).toBe("warning");
|
||||
expect(describePush(push({ ok: false, status: "conflict", error: "different total" })).title).toMatch(/conflicts/);
|
||||
expect(describePush(push({ ok: false, status: "error", error: "417", errorKind: "validation" })).kind).toBe("error");
|
||||
expect(describePaymentPush({ paymentId: 1, invoiceId: 1, ok: true, entryName: "PE-1", alreadySynced: false, error: null, errorKind: null }).kind).toBe("success");
|
||||
expect(describePaymentPush({ paymentId: 1, invoiceId: 1, ok: false, entryName: null, alreadySynced: false, error: "nope", errorKind: "other" }).kind).toBe("error");
|
||||
});
|
||||
|
||||
it("turns a rejected call into a failed row", () => {
|
||||
const r = failedPush(3, "INV/3", "ERPNext is not set up yet");
|
||||
expect(r).toMatchObject({ ok: false, invoiceId: 3, number: "INV/3", error: "ERPNext is not set up yet" });
|
||||
expect(failedPush(3, "INV/3", new Error("boom")).error).toBe("boom");
|
||||
});
|
||||
|
||||
it("summarises a batch", () => {
|
||||
const s = summarizeBatch([push(), push({ noOp: true }), push({ ok: false, status: "error", error: "x" })]);
|
||||
expect(s).toMatchObject({ total: 3, sent: 1, upToDate: 1, failed: 1, kind: "warning" });
|
||||
expect(s.title).toBe("1 sent, 1 already up to date, 1 failed");
|
||||
expect(summarizeBatch([push(), push()]).kind).toBe("success");
|
||||
expect(summarizeBatch([push({ ok: false })]).kind).toBe("error");
|
||||
expect(summarizeBatch([push({ warnings: ["w"] })]).kind).toBe("warning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto push on issue", () => {
|
||||
const invoice = { id: 7, number: "INV/2026-007" };
|
||||
|
||||
it("does nothing when it is off or ERPNext is not set up", async () => {
|
||||
const pushFn = vi.fn();
|
||||
expect(await autoPushIssued({ getConfig: async () => config(), push: pushFn }, invoice, true)).toBeNull();
|
||||
expect(await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true, apiSecretSet: false }), push: pushFn }, invoice, true)).toBeNull();
|
||||
expect(await autoPushIssued({ getConfig: async () => Promise.reject(new Error("db")), push: pushFn }, invoice, true)).toBeNull();
|
||||
expect(pushFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pushes with the configured submit setting and reports the result", async () => {
|
||||
const pushFn = vi.fn(async () => push({ number: invoice.number }));
|
||||
const notice = await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true }), push: pushFn }, invoice, true);
|
||||
expect(pushFn).toHaveBeenCalledWith(7);
|
||||
expect(notice?.kind).toBe("success");
|
||||
});
|
||||
|
||||
it("never throws: a rejected push becomes an error notice", async () => {
|
||||
const notice = await autoPushIssued(
|
||||
{ getConfig: async () => config({ autoPushOnIssue: true }), push: async () => Promise.reject("ERPNext is not set up yet") },
|
||||
invoice,
|
||||
false,
|
||||
);
|
||||
expect(notice).toMatchObject({ kind: "error" });
|
||||
expect(notice?.subtitle).toContain("not set up");
|
||||
});
|
||||
|
||||
it("explains a missing connection test instead of pushing", async () => {
|
||||
const pushFn = vi.fn();
|
||||
const notice = await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true, lastDetectResult: null }), push: pushFn }, invoice, true);
|
||||
expect(pushFn).not.toHaveBeenCalled();
|
||||
expect(notice?.subtitle).toMatch(/Run Test connection first/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnings", () => {
|
||||
it("ranks errors before warnings before notes", () => {
|
||||
expect(warningSeverity("gst_accounts_not_configured")).toBe("error");
|
||||
expect(warningSeverity("ic_not_detected")).toBe("warning");
|
||||
expect(warningSeverity("versions_unknown")).toBe("info");
|
||||
const sorted = sortWarnings([
|
||||
{ code: "versions_unknown", message: "a" },
|
||||
{ code: "company_missing", message: "b" },
|
||||
{ code: "ic_not_detected", message: "c" },
|
||||
]);
|
||||
expect(sorted.map((w) => w.code)).toEqual(["company_missing", "ic_not_detected", "versions_unknown"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings form", () => {
|
||||
it("validates the address like the backend", () => {
|
||||
expect(baseUrlProblem("")).toMatch(/Enter/);
|
||||
expect(baseUrlProblem("https://erp.example.com")).toBeNull();
|
||||
expect(baseUrlProblem("erp.example.com")).toBeNull();
|
||||
expect(baseUrlProblem("http://erp.example.com")).toMatch(/https/);
|
||||
expect(baseUrlProblem("http://localhost:8000")).toBeNull();
|
||||
expect(baseUrlProblem("localhost:8000")).toBeNull();
|
||||
expect(baseUrlProblem("http://site.test")).toBeNull();
|
||||
expect(baseUrlProblem("http://127.0.0.1:8000")).toBeNull();
|
||||
expect(baseUrlProblem("https://user:pw@erp.example.com")).toMatch(/credentials/);
|
||||
expect(baseUrlProblem("ftp://erp.example.com")).toMatch(/https/);
|
||||
});
|
||||
|
||||
it("builds the input without write-only fields and fills the UOM defaults", () => {
|
||||
const input = toInput(config({ uomMap: { hour: "Hr" } }));
|
||||
expect(input).not.toHaveProperty("apiSecretSet");
|
||||
expect(input).not.toHaveProperty("lastDetectResult");
|
||||
expect(input.apiSecret).toBe("");
|
||||
expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" });
|
||||
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
|
||||
});
|
||||
|
||||
it("needs a secret only when none is stored or it is being cleared", () => {
|
||||
const form = toInput(config());
|
||||
expect(connectionProblems(form, true)).toEqual({});
|
||||
expect(connectionProblems(form, false).apiSecret).toBeTruthy();
|
||||
expect(connectionProblems({ ...form, clearSecret: true }, true).apiSecret).toBeTruthy();
|
||||
expect(connectionProblems({ ...form, clearSecret: true, apiSecret: "new" }, true)).toEqual({});
|
||||
expect(connectionProblems({ ...form, apiKey: " " }, true).apiKey).toBeTruthy();
|
||||
});
|
||||
|
||||
it("validates a save: untouched is fine, series mode needs a series, mirror needs v2 naming", () => {
|
||||
const empty = toInput(config({ baseUrl: "", apiKey: "", apiSecretSet: false }));
|
||||
expect(validateConfigForm(empty, false, null)).toEqual({});
|
||||
const form = toInput(config());
|
||||
expect(validateConfigForm(form, true, test)).toEqual({});
|
||||
expect(validateConfigForm({ ...form, namingMode: "series", namingSeries: "" }, true, test).namingSeries).toBeTruthy();
|
||||
expect(validateConfigForm({ ...form, namingMode: "series", namingSeries: "SINV-.YY.-" }, true, test)).toEqual({});
|
||||
const old = { ...test, features: { v2Naming: false, indiaCompliance: true } };
|
||||
expect(validateConfigForm(form, true, old).namingMode).toBeTruthy();
|
||||
expect(validateConfigForm(form, true, null).namingMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a saved value visible when the loaded list lacks it", () => {
|
||||
const items = [{ name: "A", label: "A", detail: "" }];
|
||||
expect(withStoredValue(items, "A")).toBe(items);
|
||||
expect(withStoredValue(items, "")).toBe(items);
|
||||
const out = withStoredValue(items, "Sales - AC");
|
||||
expect(out[0]).toMatchObject({ name: "Sales - AC" });
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection and dates", () => {
|
||||
it("leaves cancelled invoices out of a send", () => {
|
||||
const rows = [
|
||||
{ id: 1, status: "issued" },
|
||||
{ id: 2, status: "cancelled" },
|
||||
{ id: 3, status: "issued" },
|
||||
];
|
||||
expect(sendableSelection(rows)).toEqual({ send: [rows[0], rows[2]], skipped: 1 });
|
||||
expect(sendableSelection([])).toEqual({ send: [], skipped: 0 });
|
||||
});
|
||||
|
||||
it("formats the sync time defensively", () => {
|
||||
expect(formatSyncedAt(null)).toBe("");
|
||||
expect(formatSyncedAt("not a date")).toBe("not a date");
|
||||
expect(formatSyncedAt("2026-04-01T10:30:00+00:00")).toMatch(/2026/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AUTO_DISMISS_MS, MAX_TOASTS, createToastStore } from "./toastStore";
|
||||
|
||||
function fakeTimers() {
|
||||
let next = 1;
|
||||
const pending = new Map<number, { fn: () => void; ms: number }>();
|
||||
return {
|
||||
setTimer: (fn: () => void, ms: number) => {
|
||||
const id = next++;
|
||||
pending.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id: unknown) => void pending.delete(id as number),
|
||||
fireAll: () => [...pending.entries()].forEach(([id, t]) => (pending.delete(id), t.fn())),
|
||||
pending,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toast store", () => {
|
||||
it("auto-dismisses non-error toasts after the delay", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
store.add("success", "Saved");
|
||||
store.add("info", "FYI");
|
||||
expect([...timers.pending.values()].map((t) => t.ms)).toEqual([AUTO_DISMISS_MS, AUTO_DISMISS_MS]);
|
||||
timers.fireAll();
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps errors until they are closed", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
const id = store.add("error", "Failed", "details");
|
||||
expect(timers.pending.size).toBe(0);
|
||||
timers.fireAll();
|
||||
expect(store.getSnapshot()).toEqual([{ id, kind: "error", title: "Failed", subtitle: "details" }]);
|
||||
store.dismiss(id);
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dismiss cancels the timer and is idempotent", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
const id = store.add("success", "x");
|
||||
store.dismiss(id);
|
||||
expect(timers.pending.size).toBe(0);
|
||||
store.dismiss(id);
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("notifies subscribers with a new snapshot reference per change", () => {
|
||||
const store = createToastStore(fakeTimers());
|
||||
let calls = 0;
|
||||
const off = store.subscribe(() => calls++);
|
||||
const before = store.getSnapshot();
|
||||
store.add("info", "a");
|
||||
expect(calls).toBe(1);
|
||||
expect(store.getSnapshot()).not.toBe(before);
|
||||
const same = store.getSnapshot();
|
||||
store.dismiss(999);
|
||||
expect(calls).toBe(1);
|
||||
expect(store.getSnapshot()).toBe(same);
|
||||
off();
|
||||
store.add("info", "b");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("caps the stack and drops the oldest first, cancelling its timer", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
for (let i = 0; i < MAX_TOASTS + 2; i++) store.add("success", `t${i}`);
|
||||
const titles = store.getSnapshot().map((t) => t.title);
|
||||
expect(titles).toHaveLength(MAX_TOASTS);
|
||||
expect(titles[0]).toBe("t2");
|
||||
expect(timers.pending.size).toBe(MAX_TOASTS);
|
||||
});
|
||||
|
||||
it("clear empties the stack and cancels timers", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
store.add("success", "a");
|
||||
store.add("error", "b");
|
||||
store.clear();
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
expect(timers.pending.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
export type ToastKind = "success" | "info" | "warning" | "error";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/** Success, info and warning toasts leave by themselves; errors stay until closed. */
|
||||
export const AUTO_DISMISS_MS = 5000;
|
||||
/** Oldest toasts are dropped beyond this, so a failing loop cannot fill the screen. */
|
||||
export const MAX_TOASTS = 5;
|
||||
|
||||
type TimerId = unknown;
|
||||
|
||||
export interface ToastStoreOptions {
|
||||
setTimer?: (fn: () => void, ms: number) => TimerId;
|
||||
clearTimer?: (id: TimerId) => void;
|
||||
}
|
||||
|
||||
export interface ToastStore {
|
||||
add: (kind: ToastKind, title: string, subtitle?: string) => number;
|
||||
dismiss: (id: number) => void;
|
||||
clear: () => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
getSnapshot: () => readonly Toast[];
|
||||
}
|
||||
|
||||
/** A tiny external store (useSyncExternalStore-compatible); the timers are injectable for tests. */
|
||||
export function createToastStore(options: ToastStoreOptions = {}): ToastStore {
|
||||
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
||||
const clearTimer = options.clearTimer ?? ((id) => clearTimeout(id as ReturnType<typeof setTimeout>));
|
||||
let toasts: readonly Toast[] = [];
|
||||
let nextId = 1;
|
||||
const timers = new Map<number, TimerId>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
|
||||
const drop = (id: number) => {
|
||||
const timer = timers.get(id);
|
||||
if (timer !== undefined) {
|
||||
clearTimer(timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = (id: number) => {
|
||||
drop(id);
|
||||
if (!toasts.some((t) => t.id === id)) return;
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
emit();
|
||||
};
|
||||
|
||||
return {
|
||||
add(kind, title, subtitle) {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { id, kind, title, subtitle }];
|
||||
while (toasts.length > MAX_TOASTS) {
|
||||
drop(toasts[0].id);
|
||||
toasts = toasts.slice(1);
|
||||
}
|
||||
if (kind !== "error") timers.set(id, setTimer(() => dismiss(id), AUTO_DISMISS_MS));
|
||||
emit();
|
||||
return id;
|
||||
},
|
||||
dismiss,
|
||||
clear() {
|
||||
timers.forEach((t) => clearTimer(t));
|
||||
timers.clear();
|
||||
toasts = [];
|
||||
emit();
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getSnapshot: () => toasts,
|
||||
};
|
||||
}
|
||||
@@ -58,16 +58,80 @@ export interface InvoiceSeries {
|
||||
nextInvoiceNumber: string;
|
||||
}
|
||||
|
||||
export type GstCategory = "registered_regular" | "unregistered" | "composition" | "sez" | "overseas";
|
||||
|
||||
/** Informational only; the tax heads still derive from supplier state and place of supply. */
|
||||
export const GST_CATEGORY_LABELS: Record<GstCategory, string> = {
|
||||
registered_regular: "Registered (regular)",
|
||||
unregistered: "Unregistered",
|
||||
composition: "Composition",
|
||||
sez: "SEZ",
|
||||
overseas: "Overseas",
|
||||
};
|
||||
|
||||
export interface Client {
|
||||
id: number | null;
|
||||
name: string;
|
||||
/** The composed multi-line address; what invoices store and print. */
|
||||
address: string;
|
||||
gstin: string;
|
||||
/** Doubles as the address state and the default place of supply. */
|
||||
stateCode: string;
|
||||
poNumber: string;
|
||||
createdAt: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
city: string;
|
||||
pincode: string;
|
||||
gstCategory: GstCategory;
|
||||
/** Notes / payment terms text copied onto a new invoice when the client is picked. */
|
||||
defaultNotes: string;
|
||||
/** Null means the settings default. */
|
||||
paymentTermsDays: number | null;
|
||||
/** Read-only: how many invoices reference this client. */
|
||||
invoiceCount?: number;
|
||||
}
|
||||
|
||||
export const blankClient = (): Client => ({
|
||||
id: null,
|
||||
name: "",
|
||||
address: "",
|
||||
gstin: "",
|
||||
stateCode: "",
|
||||
poNumber: "",
|
||||
createdAt: "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
pincode: "",
|
||||
gstCategory: "unregistered",
|
||||
defaultNotes: "",
|
||||
paymentTermsDays: null,
|
||||
});
|
||||
|
||||
/** A saved line item. `ratePaise` is the amount for "fixed" and the per-unit rate for "rate". */
|
||||
export interface ItemPreset {
|
||||
id: number | null;
|
||||
description: string;
|
||||
hsnSac: string;
|
||||
mode: LineMode;
|
||||
unit: LineUnit;
|
||||
ratePaise: number;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const blankPreset = (): ItemPreset => ({
|
||||
id: null,
|
||||
description: "",
|
||||
hsnSac: "",
|
||||
mode: "fixed",
|
||||
unit: "unit",
|
||||
ratePaise: 0,
|
||||
sortOrder: 0,
|
||||
createdAt: "",
|
||||
});
|
||||
|
||||
export interface InvoiceItem {
|
||||
id?: number | null;
|
||||
description: string;
|
||||
@@ -107,6 +171,9 @@ export interface InvoiceInput {
|
||||
|
||||
export type InvoiceStatus = "draft" | "issued" | "cancelled";
|
||||
|
||||
/** Derived by the backend from the payments and the due date; "none" for invoices that take no payments. */
|
||||
export type PaymentStatus = "none" | "unpaid" | "partially_paid" | "paid" | "overdue";
|
||||
|
||||
/** An invoice as stored: all amounts are derived on the Rust side. */
|
||||
export interface Invoice {
|
||||
id: number;
|
||||
@@ -150,6 +217,12 @@ export interface Invoice {
|
||||
archivedFingerprint?: string | null;
|
||||
archivedAt?: string | null;
|
||||
items: InvoiceItem[];
|
||||
/** Payment summary added by the backend (integer paise). Optional so PDF fixtures built by hand need not carry it. */
|
||||
totalPaise?: number;
|
||||
paidPaise?: number;
|
||||
tdsPaise?: number;
|
||||
balancePaise?: number;
|
||||
paymentStatus?: PaymentStatus;
|
||||
}
|
||||
|
||||
export interface DraftSummary {
|
||||
@@ -168,6 +241,12 @@ export interface InvoiceSummary {
|
||||
total: number;
|
||||
status: InvoiceStatus;
|
||||
createdAt: string;
|
||||
poNumber: string;
|
||||
totalPaise: number;
|
||||
paidPaise: number;
|
||||
tdsPaise: number;
|
||||
balancePaise: number;
|
||||
paymentStatus: PaymentStatus;
|
||||
}
|
||||
|
||||
export const EMPTY_ITEM: InvoiceItem = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
|
||||
import { clientGstinStateError, gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
|
||||
|
||||
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
|
||||
describe("GSTIN checksum", () => {
|
||||
@@ -25,3 +25,19 @@ describe("GSTIN checksum", () => {
|
||||
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientGstinStateError", () => {
|
||||
it("allows empty and NA", () => {
|
||||
expect(clientGstinStateError("", "29")).toBeUndefined();
|
||||
expect(clientGstinStateError("NA", "29")).toBeUndefined();
|
||||
});
|
||||
it("checks shape and checksum", () => {
|
||||
expect(clientGstinStateError("29ABCDE1234F1Z5", "29")).toMatch(/check digit/);
|
||||
expect(clientGstinStateError("29ABC", "")).toMatch(/15-character/);
|
||||
});
|
||||
it("checks the state prefix only when a state is chosen", () => {
|
||||
expect(clientGstinStateError("29ABCDE1234F1ZW", "29")).toBeUndefined();
|
||||
expect(clientGstinStateError("29abcde1234f1zw", "")).toBeUndefined();
|
||||
expect(clientGstinStateError("29ABCDE1234F1ZW", "27")).toMatch(/starts with 29/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,3 +84,18 @@ export function accountError(value: string): string | undefined {
|
||||
export const isPan = (v: string) => PAN_RE.test(norm(v));
|
||||
export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
|
||||
export const isGstin = (v: string) => GSTIN_RE.test(norm(v));
|
||||
|
||||
/**
|
||||
* Client GSTIN rules: shape, check digit, and that its state prefix matches the client's state when both
|
||||
* are given. Empty (or "NA") is valid; a client may be unregistered.
|
||||
*/
|
||||
export function clientGstinStateError(gstin: string, stateCode: string): string | undefined {
|
||||
const g = norm(gstin);
|
||||
if (!g || g === "NA") return undefined;
|
||||
const base = gstinFullError(g);
|
||||
if (base) return base;
|
||||
if (stateCode && g.slice(0, 2) !== stateCode) {
|
||||
return `GSTIN starts with ${g.slice(0, 2)}, but the state is ${stateCode}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -339,6 +339,25 @@ describe("closing and footer", () => {
|
||||
expect(r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" ")).join(" ")).toContain("Note 18");
|
||||
});
|
||||
|
||||
it("when only the closing block moves to page 2 the page-1 gaps are squeezed enough to keep the items and totals on page 1", async () => {
|
||||
// Row counts where the overflow before the closing block is within the gaps that help page 1 (A4 / Letter).
|
||||
const cases: Array<["A4" | "LETTER", number]> = [["A4", 7]];
|
||||
for (const [size, n] of cases) {
|
||||
const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "cobalt-stripe" }));
|
||||
const where = (id: string) => {
|
||||
const pages: number[] = [];
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id === id && pages.push(i)));
|
||||
return pages;
|
||||
};
|
||||
const rowPages = new Set<number>();
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id?.startsWith(TABLE_ROW_PREFIX) && rowPages.add(i)));
|
||||
expect([...rowPages], `${size} n=${n} rows`).toEqual([0]);
|
||||
expect(where(TOTALS_ID), `${size} n=${n} totals`).toEqual([0]);
|
||||
expect(where(CLOSING_ID), `${size} n=${n} closing`).toEqual([1]);
|
||||
expect(r.result.pages).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("an address, e-mail and phone that are too long for one row shrink and wrap inside the footer", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
|
||||
@@ -325,13 +325,17 @@ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVarian
|
||||
const nominalTotal = nominalFor(tailBody);
|
||||
const overflow = nominalTotal - frame.contentHeight;
|
||||
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
|
||||
// page 1 and let only the closing block move to the next page.
|
||||
// page 1 and let only the closing block move to the next page (its own gaps stay nominal: squeezing them would take
|
||||
// a share of the deficit away from the gaps that do help page 1).
|
||||
const overflowBeforeClosing = nominalTotal - nominalClosing.h - frame.contentHeight;
|
||||
const { terms: termsRange, footer: footerRange, ...beforeRanges } = ranges;
|
||||
const slackBefore = Object.values(beforeRanges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
|
||||
const air = (over: number) => Math.min(slack, over + FIT_AIR);
|
||||
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
|
||||
const deficit = Math.max(frame.heightDeficit, fits);
|
||||
const g = elastic(ranges, deficit);
|
||||
const air = (over: number, room: number) => Math.min(room, over + FIT_AIR);
|
||||
let g: Record<"client" | "table" | "tail" | "terms" | "footer", number>;
|
||||
if (overflow > 0 && overflow <= slack) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slack)));
|
||||
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore))), terms: termsRange.nominal, footer: footerRange.nominal };
|
||||
else g = elastic(ranges, frame.heightDeficit);
|
||||
const closing = closingFor(g);
|
||||
const tailH = g.tail + tailBody;
|
||||
const table = planTable(tailH);
|
||||
|
||||
@@ -21,6 +21,9 @@ import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
|
||||
import { atRate, LABEL_GAP, NOTES_FLOW_GAP, paymentRows, planMonolith, REVERSE_GAP, roleOf, signatureOptions, type MonolithPlan } from "./plan";
|
||||
import type { MonolithVariant } from "./tokens";
|
||||
|
||||
/** Room that must remain under the notes' label and first line, so the next lines are not squeezed into the page foot. */
|
||||
const NOTES_HOLD = 70;
|
||||
|
||||
/** Layout-tree ids of the blocks that are Monolith's own (the harness ids are in classic/ids.ts). */
|
||||
export const BRAND_ID = "monolith-brand";
|
||||
export const IDS_ID = "monolith-ids";
|
||||
@@ -348,11 +351,16 @@ export function MonolithBody({ model, frame, logo, prefs, variant: v }: Template
|
||||
const plan = planMonolith(model, frame, v, logo, includesName({ model, prefs }));
|
||||
const ctx: Ctx = { model, plan, v, logo };
|
||||
const r = v.roles;
|
||||
const notesFlow: ReactNode = plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
|
||||
<View style={{ marginTop: NOTES_FLOW_GAP }}>
|
||||
<Notes ctx={ctx} width={plan.W} />
|
||||
</View>
|
||||
);
|
||||
const notesFlow: ReactNode =
|
||||
plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
|
||||
<>
|
||||
{/* A block that starts with less than a line of room left is cut into zero-height lines: it starts on the next page instead. */}
|
||||
<TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} />
|
||||
<View style={{ marginTop: NOTES_FLOW_GAP }}>
|
||||
<Notes ctx={ctx} width={plan.W} />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FirstPageHeader pad={frame.pad}>
|
||||
|
||||
@@ -286,6 +286,41 @@ describe("long text", () => {
|
||||
expect(r.pdf.pages.map((p, i) => textsOf(r, i)).join(" ")).toContain("Note 18");
|
||||
});
|
||||
|
||||
it("when only the closing block moves to page 2 the page-1 gaps are squeezed enough to keep the items and totals on page 1", async () => {
|
||||
// Row counts where the overflow before the closing block is within the gaps that help page 1 (A4 / Letter).
|
||||
const cases: Array<["A4" | "LETTER", number]> = [["A4", 4], ["A4", 5], ["LETTER", 4]];
|
||||
for (const [size, n] of cases) {
|
||||
const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" }));
|
||||
const where = (id: string) => {
|
||||
const pages: number[] = [];
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id === id && pages.push(i)));
|
||||
return pages;
|
||||
};
|
||||
const rowPages = new Set<number>();
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id?.startsWith(TABLE_ROW_PREFIX) && rowPages.add(i)));
|
||||
expect([...rowPages], `${size} n=${n} rows`).toEqual([0]);
|
||||
expect(where(TOTALS_ID), `${size} n=${n} totals`).toEqual([0]);
|
||||
expect(where(CLOSING_ID), `${size} n=${n} closing`).toEqual([1]);
|
||||
expect(r.result.pages).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("flow notes never start with less than a line of room: the block starts on the next page instead of collapsing into zero-height lines", async () => {
|
||||
const base = FIXTURES.find((f) => f.name === "long-notes")!;
|
||||
// Row counts where the notes' label used to land in the last few points of a page (A4 / Letter).
|
||||
const cases: Array<["A4" | "LETTER", number]> = [["A4", 3], ["A4", 14], ["LETTER", 13]];
|
||||
for (const [size, n] of cases) {
|
||||
const r = await renderFixture({ ...base, items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error"), `${size} n=${n}`).toEqual([]);
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const label = nodes(r.result.core.layout).find((x) => x.type === "text" && x.lines?.some((l) => /^notes/i.test(l.text)))!;
|
||||
let page = -1;
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x === label && (page = i)));
|
||||
expect(label.box.y + 70, `${size} n=${n} label room`).toBeLessThanOrEqual(frame.page.height - frame.pad.bottom);
|
||||
expect(page).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("an over-long vendor name wraps right-aligned in its column and never reaches the title", async () => {
|
||||
const r = await renderFixture({ ...FIXTURES.find((f) => f.name === "id-tokens")!, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
|
||||
const brand = textLines(byId(r, BRAND_ID));
|
||||
|
||||
@@ -235,15 +235,20 @@ export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVa
|
||||
const notesBlock = notesBeside ? 0 : NOTES_FLOW_GAP + flowH;
|
||||
const nominalTotal = headH + v.headRuleGap + v.rule + ranges.infoTop.nominal + infoH + ranges.infoBottom.nominal + v.rule + ranges.band.nominal + tableTotal + tailH + notesBlock + closingNominal;
|
||||
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
// The closing block's own gaps cannot help page 1 once it has moved to page 2.
|
||||
const { closeBand: closeBandRange, closeBottom: closeBottomRange, ...beforeRanges } = ranges;
|
||||
const slackBefore = Object.values(beforeRanges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
const overflow = nominalTotal - frame.contentHeight;
|
||||
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
|
||||
// page 1 and let only the closing block move to the next page.
|
||||
// page 1 and let only the closing block move to the next page (its own gaps stay nominal: squeezing them would take
|
||||
// a share of the deficit away from the gaps that do help page 1).
|
||||
const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight;
|
||||
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
|
||||
const air = (over: number) => Math.min(slack, over + FIT_AIR);
|
||||
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
|
||||
const deficit = Math.max(frame.heightDeficit, fits);
|
||||
const g = elastic(ranges, deficit);
|
||||
const air = (over: number, room: number) => Math.min(room, over + FIT_AIR);
|
||||
let g: Record<"band" | "infoTop" | "infoBottom" | "closeBand" | "closeBottom" | "rowPad", number>;
|
||||
if (overflow > 0 && overflow <= slack) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slack)));
|
||||
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore))), closeBand: closeBandRange.nominal, closeBottom: closeBottomRange.nominal };
|
||||
else g = elastic(ranges, frame.heightDeficit);
|
||||
const closingH = closingFor(g);
|
||||
const padY = n ? g.rowPad / (2 * n) : padYn;
|
||||
const table = planTable(padY, tailH);
|
||||
|
||||
@@ -148,6 +148,23 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.voiced-toasts {
|
||||
position: fixed;
|
||||
top: 3.5rem; /* below the 3rem Carbon header */
|
||||
right: 1rem;
|
||||
z-index: 9100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: calc(100vw - 2rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.voiced-toasts .cds--toast-notification {
|
||||
pointer-events: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.voiced-page {
|
||||
padding: 2rem;
|
||||
max-width: 1600px;
|
||||
@@ -523,3 +540,234 @@ body {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ---------- Settings: Data tab ---------- */
|
||||
.voiced-section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 400;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.voiced-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.voiced-list {
|
||||
margin-left: 1.25rem;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
/* ---------- Invoice history and detail ---------- */
|
||||
.voiced-num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.voiced-history__fy {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 400;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.voiced-history__tiles,
|
||||
.voiced-detail__tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.voiced-history__amount,
|
||||
.voiced-detail__amount {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.voiced-history__tile--overdue {
|
||||
border-left: 4px solid var(--cds-support-error);
|
||||
}
|
||||
|
||||
.voiced-history__filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 2fr) repeat(4, minmax(9rem, 1fr)) auto;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.voiced-history__export {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.voiced-history__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voiced-history__totals {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 2rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--cds-layer-accent);
|
||||
border-top: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.voiced-detail__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.voiced-detail__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.voiced-detail__grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.voiced-detail__grid,
|
||||
.voiced-history__filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.voiced-detail__h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 400;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.voiced-detail__parties {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.voiced-detail__field {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.voiced-detail__pre {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.voiced-detail__totals {
|
||||
margin: 1rem 0 0.5rem auto;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.voiced-detail__totals > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.voiced-detail__grand {
|
||||
border-top: 1px solid var(--cds-border-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.voiced-detail__preview {
|
||||
position: sticky;
|
||||
top: 4rem;
|
||||
}
|
||||
|
||||
.voiced-detail__unarchived {
|
||||
padding: 1.5rem;
|
||||
background: var(--cds-layer-accent);
|
||||
}
|
||||
|
||||
.voiced-detail__unarchived p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Settings, Integrations, ERPNext */
|
||||
.voiced-erp__section > h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.voiced-erp__fields {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 40rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.voiced-erp__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--cds-text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.voiced-erp__secret > * + * {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.voiced-erp__result > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.voiced-erp__uoms {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.voiced-erp__uoms legend {
|
||||
font-size: 0.75rem;
|
||||
color: var(--cds-text-secondary);
|
||||
}
|
||||
|
||||
.voiced-erp__error {
|
||||
color: var(--cds-text-error);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ERPNext column and bulk-send bar on the invoices list */
|
||||
.voiced-history__bulk {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.voiced-erp__results {
|
||||
list-style: none;
|
||||
margin-top: 1rem;
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.voiced-erp__results li {
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.voiced-erp__panel {
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.voiced-erp__panel > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
+137
-101
@@ -3,7 +3,6 @@ import {
|
||||
Button,
|
||||
Column,
|
||||
Grid,
|
||||
InlineNotification,
|
||||
Link,
|
||||
NumberInput,
|
||||
RadioButton,
|
||||
@@ -19,6 +18,11 @@ import {
|
||||
TableRow,
|
||||
TextArea,
|
||||
TextInput,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
Tile,
|
||||
Toggle,
|
||||
} from "@carbon/react";
|
||||
@@ -35,8 +39,13 @@ import {
|
||||
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
import { LogoBranding } from "../components/LogoBranding";
|
||||
import DataBackupPanel from "../components/DataBackupPanel";
|
||||
import DiagnosticsPanel from "../components/DiagnosticsPanel";
|
||||
import ErpnextSettingsPanel from "../components/ErpnextSettingsPanel";
|
||||
import { FontOverrideTable } from "../components/FontOverrideTable";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
|
||||
import SeriesSettings from "./SeriesSettings";
|
||||
|
||||
const blankBank = (): BankAccount => ({
|
||||
id: null,
|
||||
@@ -61,14 +70,20 @@ export default function AppSettings({
|
||||
const [banks, setBanks] = useState<BankAccount[]>([]);
|
||||
const [editing, setEditing] = useState<BankAccount | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
// One form state for every tab and one Save button, so edits survive switching tabs.
|
||||
const [tab, setTab] = useState(loadSettingsTab);
|
||||
|
||||
const selectTab = (index: number) => {
|
||||
setTab(index);
|
||||
saveSettingsTab(index);
|
||||
};
|
||||
|
||||
const loadBanks = async () => {
|
||||
try {
|
||||
setBanks(await api.listBanks());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not load bank accounts", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,18 +107,17 @@ export default function AppSettings({
|
||||
|
||||
const saveAll = async () => {
|
||||
if (gstinProblem) {
|
||||
setError(gstinProblem);
|
||||
selectTab(0);
|
||||
toast.error("Settings not saved", gstinProblem);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.saveSettings(form);
|
||||
setNotice("Settings saved.");
|
||||
toast.success("Settings saved");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not save settings", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -112,7 +126,6 @@ export default function AppSettings({
|
||||
const saveBank = async () => {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await api.saveBank(editing);
|
||||
if (saved.isDefault || form.defaultBankId === null) {
|
||||
@@ -120,8 +133,9 @@ export default function AppSettings({
|
||||
}
|
||||
setEditing(null);
|
||||
await loadBanks();
|
||||
toast.success("Bank account saved", "Click Save settings to keep a changed default account.");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not save bank account", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -139,30 +153,30 @@ export default function AppSettings({
|
||||
if (form.defaultBankId === bank.id) set("defaultBankId", null);
|
||||
await loadBanks();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not delete bank account", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Settings</h1>
|
||||
<p className="voiced-page__subtitle">Vendor details, defaults and bank accounts.</p>
|
||||
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||
) : null}
|
||||
{notice ? (
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
title="Saved"
|
||||
subtitle={notice}
|
||||
onCloseButtonClick={() => setNotice(null)}
|
||||
/>
|
||||
) : null}
|
||||
<p className="voiced-page__subtitle">Business details, branding, defaults, bank accounts and integrations.</p>
|
||||
|
||||
<Tabs selectedIndex={tab} onChange={({ selectedIndex }) => selectTab(selectedIndex)}>
|
||||
<TabList aria-label="Settings sections" contained scrollIntoView>
|
||||
<Tab>Business</Tab>
|
||||
<Tab>Branding</Tab>
|
||||
<Tab>Bank & payments</Tab>
|
||||
<Tab>Invoice defaults</Tab>
|
||||
<Tab>Numbering</Tab>
|
||||
<Tab>Templates & export</Tab>
|
||||
<Tab>Fonts</Tab>
|
||||
<Tab>Data</Tab>
|
||||
<Tab>Integrations</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Vendor details</h3>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<RadioButtonGroup
|
||||
@@ -277,9 +291,9 @@ export default function AppSettings({
|
||||
</Column>
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Branding</h3>
|
||||
<Tile>
|
||||
<LogoBranding
|
||||
idPrefix="s"
|
||||
@@ -289,78 +303,8 @@ export default function AppSettings({
|
||||
/>
|
||||
</Tile>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Fonts</h3>
|
||||
<Tile>
|
||||
<FontOverrideTable />
|
||||
</Tile>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Defaults</h3>
|
||||
<Grid narrow>
|
||||
{registered ? (
|
||||
<>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-taxRate"
|
||||
label="Default tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={form.defaultTaxRate}
|
||||
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="s-hsn"
|
||||
labelText="Default HSN/SAC"
|
||||
value={form.defaultHsnSac}
|
||||
onChange={(e) => set("defaultHsnSac", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
</>
|
||||
) : null}
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-terms"
|
||||
label="Payment terms (days)"
|
||||
min={0}
|
||||
max={365}
|
||||
value={form.paymentTermsDays}
|
||||
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={6}>
|
||||
<Select
|
||||
id="s-theme"
|
||||
labelText="Theme"
|
||||
value={form.theme}
|
||||
onChange={(e) => set("theme", e.target.value)}
|
||||
>
|
||||
<SelectItem value="g10" text="Carbon light (g10)" />
|
||||
<SelectItem value="g100" text="Carbon dark (g100)" />
|
||||
</Select>
|
||||
</Column>
|
||||
{registered ? (
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<RadioButtonGroup
|
||||
legendText="Default tax treatment"
|
||||
name="s-taxType"
|
||||
valueSelected={form.defaultTaxType}
|
||||
onChange={(value) => set("defaultTaxType", value as TaxType)}
|
||||
>
|
||||
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
) : null}
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3>Bank accounts</h3>
|
||||
@@ -487,6 +431,98 @@ export default function AppSettings({
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<Grid narrow>
|
||||
{registered ? (
|
||||
<>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-taxRate"
|
||||
label="Default tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={form.defaultTaxRate}
|
||||
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="s-hsn"
|
||||
labelText="Default HSN/SAC"
|
||||
value={form.defaultHsnSac}
|
||||
onChange={(e) => set("defaultHsnSac", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
</>
|
||||
) : null}
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-terms"
|
||||
label="Payment terms (days)"
|
||||
min={0}
|
||||
max={365}
|
||||
value={form.paymentTermsDays}
|
||||
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={6}>
|
||||
<Select
|
||||
id="s-theme"
|
||||
labelText="Theme"
|
||||
value={form.theme}
|
||||
onChange={(e) => set("theme", e.target.value)}
|
||||
>
|
||||
<SelectItem value="g10" text="Carbon light (g10)" />
|
||||
<SelectItem value="g100" text="Carbon dark (g100)" />
|
||||
</Select>
|
||||
</Column>
|
||||
{registered ? (
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<RadioButtonGroup
|
||||
legendText="Default tax treatment"
|
||||
name="s-taxType"
|
||||
valueSelected={form.defaultTaxType}
|
||||
onChange={(value) => set("defaultTaxType", value as TaxType)}
|
||||
>
|
||||
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
) : null}
|
||||
</Grid>
|
||||
</section>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<SeriesSettings embedded active={tab === SETTINGS_TABS.findIndex((t) => t.id === "numbering")} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack">
|
||||
<p>
|
||||
The template, page size and margins are chosen next to the preview on the New invoice screen. The
|
||||
default page setup is saved from there.
|
||||
</p>
|
||||
<p className="voiced-muted">Export mode (searchable or flattened) is chosen when you issue an invoice.</p>
|
||||
</section>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<Tile>
|
||||
<FontOverrideTable />
|
||||
</Tile>
|
||||
</section>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<DataBackupPanel />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<ErpnextSettingsPanel active={tab === SETTINGS_TABS.findIndex((t) => t.id === "integrations")} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
|
||||
{busy ? "Saving…" : "Save settings"}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DataTable,
|
||||
InlineLoading,
|
||||
Modal,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableToolbar,
|
||||
TableToolbarContent,
|
||||
TableToolbarSearch,
|
||||
Tabs,
|
||||
Tag,
|
||||
} from "@carbon/react";
|
||||
import { Add, Edit, TrashCan } from "@carbon/icons-react";
|
||||
import { api } from "../lib/api";
|
||||
import { GST_CATEGORY_LABELS, INDIAN_STATES, type Client } from "../lib/types";
|
||||
import ClientModal from "../components/ClientModal";
|
||||
import ItemPresetsPanel from "../components/ItemPresetsPanel";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
|
||||
const HEADERS = [
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "gstin", header: "GSTIN" },
|
||||
{ key: "category", header: "GST category" },
|
||||
{ key: "place", header: "City / State" },
|
||||
{ key: "invoices", header: "Invoices" },
|
||||
{ key: "actions", header: "" },
|
||||
];
|
||||
|
||||
const stateName = (code: string) => INDIAN_STATES.find((s) => s.code === code)?.name ?? "";
|
||||
|
||||
export default function Clients({ active }: { active: boolean }) {
|
||||
const toast = useToast();
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<Client | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState<Client | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const launcher = useRef<HTMLElement | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setClients(await api.listClients());
|
||||
} catch (e) {
|
||||
toast.error("Could not load clients", String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// The view stays mounted, so refetch whenever it is shown again.
|
||||
useEffect(() => {
|
||||
if (active) void load();
|
||||
}, [active, load]);
|
||||
|
||||
const openEditor = (client: Client | null, trigger: HTMLElement) => {
|
||||
launcher.current = trigger;
|
||||
setEditing(client);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleting || deleting.id === null) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteClient(deleting.id);
|
||||
toast.success("Client deleted", deleting.name);
|
||||
setDeleting(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error("Could not delete client", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const byId = new Map(clients.map((c) => [String(c.id), c]));
|
||||
const rows = clients.map((c) => ({
|
||||
id: String(c.id),
|
||||
name: c.name,
|
||||
gstin: c.gstin || "—",
|
||||
category: GST_CATEGORY_LABELS[c.gstCategory] ?? c.gstCategory,
|
||||
place: [c.city, stateName(c.stateCode)].filter(Boolean).join(", ") || "—",
|
||||
invoices: c.invoiceCount ?? 0,
|
||||
actions: "",
|
||||
}));
|
||||
|
||||
const clientsTable = loading ? (
|
||||
<InlineLoading description="Loading clients" />
|
||||
) : (
|
||||
<DataTable rows={rows} headers={HEADERS} isSortable>
|
||||
{({ rows: tableRows, headers, getHeaderProps, getRowProps, getTableProps, onInputChange }) => (
|
||||
<TableContainer>
|
||||
<TableToolbar>
|
||||
<TableToolbarContent>
|
||||
<TableToolbarSearch
|
||||
persistent
|
||||
placeholder="Search clients"
|
||||
labelText="Search clients"
|
||||
onChange={(e) => onInputChange(e as React.ChangeEvent<HTMLInputElement>)}
|
||||
/>
|
||||
<Button
|
||||
renderIcon={Add}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(null, e.currentTarget)}
|
||||
>
|
||||
Add client
|
||||
</Button>
|
||||
</TableToolbarContent>
|
||||
</TableToolbar>
|
||||
<Table {...getTableProps()} aria-label="Clients">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headers.map((header) => {
|
||||
const { key, ...headerProps } = getHeaderProps({ header });
|
||||
return header.key === "actions" ? (
|
||||
<TableHeader key={key} aria-label="Actions" />
|
||||
) : (
|
||||
<TableHeader key={key} {...headerProps}>
|
||||
{header.header}
|
||||
</TableHeader>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tableRows.map((row) => {
|
||||
const client = byId.get(row.id);
|
||||
const { key, ...rowProps } = getRowProps({ row });
|
||||
return (
|
||||
<TableRow key={key} {...rowProps}>
|
||||
{row.cells.map((cell) => {
|
||||
if (cell.info.header === "actions") {
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
{client ? (
|
||||
<div className="voiced-header-action">
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Edit}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(client, e.currentTarget)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={TrashCan}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
||||
launcher.current = e.currentTarget;
|
||||
setDeleting(client);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (cell.info.header === "category" && client) {
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
<Tag type={client.gstCategory === "unregistered" ? "gray" : "blue"} size="sm">
|
||||
{cell.value}
|
||||
</Tag>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
return <TableCell key={cell.id}>{cell.value}</TableCell>;
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{clients.length === 0 ? <div className="voiced-empty">No clients yet. Add one to reuse it on invoices.</div> : null}
|
||||
</TableContainer>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
|
||||
const count = deleting?.invoiceCount ?? 0;
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Clients</h1>
|
||||
<p className="voiced-page__subtitle">Saved clients and item presets for faster invoicing.</p>
|
||||
|
||||
<Tabs>
|
||||
<TabList aria-label="Clients and presets" contained>
|
||||
<Tab>Clients</Tab>
|
||||
<Tab>Item presets</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>{clientsTable}</TabPanel>
|
||||
<TabPanel>
|
||||
<ItemPresetsPanel active={active} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<ClientModal
|
||||
open={modalOpen}
|
||||
client={editing}
|
||||
launcherRef={launcher}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={() => {
|
||||
setModalOpen(false);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
danger
|
||||
open={deleting !== null}
|
||||
modalHeading="Delete client"
|
||||
primaryButtonText={busy ? "Deleting…" : "Delete"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={() => setDeleting(null)}
|
||||
onRequestSubmit={() => void confirmDelete()}
|
||||
launcherButtonRef={launcher}
|
||||
size="xs"
|
||||
>
|
||||
<p>
|
||||
Delete “{deleting?.name}”?{" "}
|
||||
{count > 0
|
||||
? `${count} invoice${count === 1 ? "" : "s"} refer${count === 1 ? "s" : ""} to this client. They keep their own copy of the client's name, address and GSTIN, so they are not changed.`
|
||||
: "No invoices refer to this client."}
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
Link,
|
||||
Modal,
|
||||
OverflowMenu,
|
||||
OverflowMenuItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tag,
|
||||
TextInput,
|
||||
Tile,
|
||||
} from "@carbon/react";
|
||||
import { ArrowLeft, TrashCan } from "@carbon/icons-react";
|
||||
import { api, type ArchiveStatus } from "../lib/api";
|
||||
import { formatAmount, formatDate } from "../lib/format";
|
||||
import { formatPaise } from "../lib/money";
|
||||
import { paymentModeLabel, statusBadge, type Payment } from "../lib/payments";
|
||||
import { createExportDeps } from "../lib/exportDeps";
|
||||
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
|
||||
import { toDraftPayload } from "../lib/draft";
|
||||
import { invoiceToFormState } from "../lib/duplicate";
|
||||
import type { ExportMode } from "../lib/filename";
|
||||
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||
import { useGenericExport } from "../hooks/useGenericExport";
|
||||
import { openInErpnext } from "../lib/erpnextActions";
|
||||
import type { ErpnextConfig, ErpnextPushResult, ErpnextSyncStatus } from "../lib/erpnext";
|
||||
import {
|
||||
canOpenInErpnext,
|
||||
describePaymentPush,
|
||||
describePush,
|
||||
failedPush,
|
||||
formatSyncedAt,
|
||||
isConfigured,
|
||||
paymentPushDisabledReason,
|
||||
pushDisabledReason,
|
||||
syncTag,
|
||||
} from "../lib/erpnextUi";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
import ExportFeedback from "../components/ExportFeedback";
|
||||
import PdfPreview from "../components/PdfPreview";
|
||||
import RecordPaymentModal from "../components/RecordPaymentModal";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
invoiceId: number;
|
||||
settings: Settings;
|
||||
onBack: () => void;
|
||||
/** A draft was created from this invoice; the shell opens it in the New invoice form. */
|
||||
onDuplicated: (draftId: number) => void;
|
||||
/** Goes to Settings, Integrations. */
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
interface Frozen {
|
||||
vendorName?: string;
|
||||
vendorAddress?: string;
|
||||
vendorGstin?: string;
|
||||
vendorPan?: string;
|
||||
vendorEmail?: string;
|
||||
vendorPhone?: string;
|
||||
}
|
||||
|
||||
function parseSnapshot(text: string): Frozen {
|
||||
try {
|
||||
return text ? (JSON.parse(text) as Frozen) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const stateName = (code: string) => {
|
||||
const s = INDIAN_STATES.find((x) => x.code === code);
|
||||
return s ? `${code} - ${s.name}` : code;
|
||||
};
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="voiced-detail__field">
|
||||
<dt className="voiced-muted">{label}</dt>
|
||||
<dd>{children || "—"}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated, onOpenSettings }: Props) {
|
||||
const toast = useToast();
|
||||
const [invoice, setInvoice] = useState<Invoice | null>(null);
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [archive, setArchive] = useState<ArchiveStatus | null>(null);
|
||||
const [pdf, setPdf] = useState<Uint8Array | null>(null);
|
||||
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||
const [archiving, setArchiving] = useState(false);
|
||||
const [recordOpen, setRecordOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Payment | null>(null);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [duplicating, setDuplicating] = useState(false);
|
||||
const [erpCfg, setErpCfg] = useState<ErpnextConfig | null>(null);
|
||||
const [erpSync, setErpSync] = useState<ErpnextSyncStatus | undefined>(undefined);
|
||||
const [lastPush, setLastPush] = useState<ErpnextPushResult | null>(null);
|
||||
const [sendingErp, setSendingErp] = useState(false);
|
||||
const [sendingPayment, setSendingPayment] = useState<number | null>(null);
|
||||
const generic = useGenericExport();
|
||||
useReturnFocus(deleteTarget !== null);
|
||||
useReturnFocus(cancelOpen);
|
||||
|
||||
// Guards against a slow load for a previous id landing after the id changed.
|
||||
const generation = useRef(0);
|
||||
|
||||
const loadPdf = useCallback(async (id: number, gen: number) => {
|
||||
setPdfError(null);
|
||||
try {
|
||||
const status = await api.archiveStatus(id);
|
||||
if (gen !== generation.current) return;
|
||||
setArchive(status);
|
||||
if (status.archived) {
|
||||
const bytes = await api.readArchive(id);
|
||||
if (gen === generation.current) setPdf(bytes);
|
||||
} else {
|
||||
setPdf(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (gen === generation.current) setPdfError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async (id: number, gen: number) => {
|
||||
try {
|
||||
const [inv, pays] = await Promise.all([api.getInvoice(id), api.listPayments(id)]);
|
||||
if (gen !== generation.current) return;
|
||||
setInvoice(inv);
|
||||
setPayments(pays);
|
||||
setLoadError(null);
|
||||
} catch (e) {
|
||||
if (gen === generation.current) setLoadError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** ERPNext is optional: when it cannot be read the panel just stays out of the way. */
|
||||
const loadErp = useCallback(async (id: number, gen: number) => {
|
||||
try {
|
||||
const [cfg, sync] = await Promise.all([api.erpnextGetConfig(), api.erpnextSyncStatus(id)]);
|
||||
if (gen !== generation.current) return;
|
||||
setErpCfg(cfg);
|
||||
setErpSync(sync);
|
||||
} catch {
|
||||
if (gen === generation.current) {
|
||||
setErpCfg(null);
|
||||
setErpSync(undefined);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++generation.current;
|
||||
setInvoice(null);
|
||||
setPdf(null);
|
||||
setArchive(null);
|
||||
setLastPush(null);
|
||||
void loadData(invoiceId, gen);
|
||||
void loadPdf(invoiceId, gen);
|
||||
void loadErp(invoiceId, gen);
|
||||
}, [invoiceId, loadData, loadPdf, loadErp]);
|
||||
|
||||
const refresh = () => loadData(invoiceId, generation.current);
|
||||
|
||||
const exp = useInvoiceExport({
|
||||
settings,
|
||||
bank: null,
|
||||
// An export that had to archive the original first: show it in the preview.
|
||||
onInvoiceUpdated: () => void loadPdf(invoiceId, generation.current),
|
||||
});
|
||||
|
||||
const busy = exp.busy || archiving || duplicating;
|
||||
|
||||
const onExport = (mode: ExportMode, source: ExportSource) => {
|
||||
if (!invoice) return;
|
||||
void exp.start(invoice, mode, source).catch((e) => toast.error("Export failed", String(e)));
|
||||
};
|
||||
|
||||
const onArchive = async () => {
|
||||
if (!invoice) return;
|
||||
setArchiving(true);
|
||||
try {
|
||||
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
|
||||
if (outcome.archived) {
|
||||
toast.success(`Invoice ${invoice.number} is archived`);
|
||||
await Promise.all([loadPdf(invoiceId, generation.current), refresh()]);
|
||||
} else {
|
||||
toast.error("Could not archive the PDF", outcome.error ?? "The PDF could not be archived.");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Could not archive the PDF", String(e));
|
||||
} finally {
|
||||
setArchiving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDuplicate = async () => {
|
||||
if (!invoice) return;
|
||||
setDuplicating(true);
|
||||
try {
|
||||
const id = await api.saveDraft(null, toDraftPayload(invoiceToFormState(invoice, settings)));
|
||||
toast.success("Draft created from this invoice", "Review it, then issue it as a new invoice.");
|
||||
onDuplicated(id);
|
||||
} catch (e) {
|
||||
toast.error("Could not duplicate the invoice", String(e));
|
||||
} finally {
|
||||
setDuplicating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCancelInvoice = async () => {
|
||||
if (!invoice) return;
|
||||
try {
|
||||
await api.cancelInvoice(invoice.id, reason);
|
||||
toast.success(`Invoice ${invoice.number} cancelled`);
|
||||
setCancelOpen(false);
|
||||
setReason("");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setCancelOpen(false);
|
||||
toast.error("Could not cancel the invoice", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
/** `submit` undefined keeps what the invoice already is in ERPNext. A failure is shown, never thrown. */
|
||||
const onSendErp = async (submit: boolean) => {
|
||||
if (!invoice) return;
|
||||
setSendingErp(true);
|
||||
let result: ErpnextPushResult;
|
||||
try {
|
||||
result = await api.erpnextPushInvoice(invoice.id, submit);
|
||||
} catch (e) {
|
||||
result = failedPush(invoice.id, invoice.number, e);
|
||||
}
|
||||
setLastPush(result);
|
||||
const note = describePush(result);
|
||||
toast[note.kind](note.title, note.subtitle);
|
||||
await loadErp(invoiceId, generation.current);
|
||||
setSendingErp(false);
|
||||
};
|
||||
|
||||
const onOpenErp = async () => {
|
||||
try {
|
||||
await openInErpnext(invoiceId);
|
||||
} catch (e) {
|
||||
toast.error("Could not open ERPNext", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const onSendPayment = async (paymentId: number) => {
|
||||
setSendingPayment(paymentId);
|
||||
try {
|
||||
const note = describePaymentPush(await api.erpnextPushPayment(paymentId));
|
||||
toast[note.kind](note.title, note.subtitle);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.error("Could not send the payment", String(e));
|
||||
} finally {
|
||||
setSendingPayment(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeletePayment = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const target = deleteTarget;
|
||||
setDeleteTarget(null);
|
||||
try {
|
||||
await api.deletePayment(target.id);
|
||||
toast.success("Payment deleted", `${formatPaise(target.amountPaise + target.tdsPaise)} on ${formatDate(target.paidOn)}`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.error("Could not delete the payment", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
|
||||
Back to invoices
|
||||
</Button>
|
||||
<InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the invoice" subtitle={loadError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<InlineLoading description="Loading invoice" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = statusBadge(invoice.status, invoice.paymentStatus);
|
||||
const vendor = parseSnapshot(invoice.vendorSnapshot);
|
||||
const total = invoice.totalPaise ?? Math.round(invoice.total * 100);
|
||||
const balance = invoice.balancePaise ?? 0;
|
||||
const canPay = invoice.status === "issued" && balance > 0;
|
||||
const half = invoice.taxRate / 2;
|
||||
const erpOn = isConfigured(erpCfg);
|
||||
const erpTag = syncTag(erpSync);
|
||||
const erpBlocked = pushDisabledReason({ cfg: erpCfg, vendorRegistered: settings.gstRegistration !== "unregistered", invoiceStatus: invoice.status });
|
||||
const erpHasState = Boolean(erpSync && erpSync.status !== "none");
|
||||
const erpSubmitted = erpSync?.status === "synced" && erpSync.remoteDocstatus === 1;
|
||||
const showPaymentErp = erpOn || payments.some((p) => p.erpnextPaymentEntry);
|
||||
|
||||
return (
|
||||
<div className="voiced-page voiced-detail">
|
||||
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
|
||||
Back to invoices
|
||||
</Button>
|
||||
|
||||
<div className="voiced-detail__head">
|
||||
<h1 className="voiced-page__title">{invoice.number}</h1>
|
||||
<Tag type={badge.type}>{badge.label}</Tag>
|
||||
{invoice.status === "issued" && invoice.paymentStatus === "overdue" && (invoice.paidPaise ?? 0) + (invoice.tdsPaise ?? 0) > 0 ? (
|
||||
<Tag type="blue" size="sm">
|
||||
Partly paid
|
||||
</Tag>
|
||||
) : null}
|
||||
{archive && !archive.archived ? (
|
||||
<Tag type="warm-gray" size="sm">
|
||||
Not archived
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="voiced-page__subtitle">
|
||||
{invoice.clientName || "No client name"} · issued {formatDate(invoice.invoiceDate)}
|
||||
{invoice.dueDate ? ` · due ${formatDate(invoice.dueDate)}` : ""}
|
||||
</p>
|
||||
|
||||
{invoice.status === "cancelled" ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="This invoice is cancelled"
|
||||
subtitle={invoice.cancelReason ? `Reason: ${invoice.cancelReason}` : "Its number is not reused."}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="voiced-detail__actions" role="toolbar" aria-label="Invoice actions">
|
||||
{canPay ? (
|
||||
<Button size="md" onClick={() => setRecordOpen(true)} disabled={busy}>
|
||||
Record payment
|
||||
</Button>
|
||||
) : null}
|
||||
<Button kind="tertiary" size="md" onClick={() => onExport("searchable", "original")} disabled={busy}>
|
||||
Export PDF
|
||||
</Button>
|
||||
<Button kind="tertiary" size="md" onClick={() => onExport("flattened", "original")} disabled={busy}>
|
||||
Export flattened
|
||||
</Button>
|
||||
<Button kind="tertiary" size="md" onClick={() => void onDuplicate()} disabled={busy}>
|
||||
{duplicating ? "Duplicating…" : "Duplicate"}
|
||||
</Button>
|
||||
<OverflowMenu aria-label="More actions" iconDescription="More actions" flipped size="md" disabled={busy}>
|
||||
<OverflowMenuItem itemText="Re-render with current template" onClick={() => onExport("searchable", "rerender")} />
|
||||
<OverflowMenuItem itemText="Export as voiced.invoice.v1 (JSON)" hasDivider onClick={() => void generic.run([invoice.id], "json")} />
|
||||
<OverflowMenuItem itemText="Export line items (CSV)" onClick={() => void generic.run([invoice.id], "csv")} />
|
||||
{invoice.status === "issued" ? (
|
||||
<OverflowMenuItem
|
||||
itemText={payments.length > 0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"}
|
||||
hasDivider
|
||||
isDelete
|
||||
disabled={payments.length > 0}
|
||||
onClick={() => {
|
||||
setReason("");
|
||||
setCancelOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
|
||||
<ExportFeedback exp={exp} />
|
||||
|
||||
<div className="voiced-detail__grid">
|
||||
<div className="voiced-detail__main voiced-stack">
|
||||
<div className="voiced-detail__tiles">
|
||||
<Tile>
|
||||
<p className="voiced-muted">Total</p>
|
||||
<p className="voiced-detail__amount">{formatPaise(total)}</p>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<p className="voiced-muted">Received</p>
|
||||
<p className="voiced-detail__amount">{formatPaise(invoice.paidPaise ?? 0)}</p>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<p className="voiced-muted">TDS deducted</p>
|
||||
<p className="voiced-detail__amount">{formatPaise(invoice.tdsPaise ?? 0)}</p>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<p className="voiced-muted">Balance</p>
|
||||
<p className="voiced-detail__amount">{formatPaise(balance)}</p>
|
||||
</Tile>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="detail-parties">
|
||||
<h2 id="detail-parties" className="voiced-detail__h2">
|
||||
Parties
|
||||
</h2>
|
||||
<div className="voiced-detail__parties">
|
||||
<dl>
|
||||
<Field label="Billed to">{invoice.clientName}</Field>
|
||||
<Field label="Address">
|
||||
<span className="voiced-detail__pre">{invoice.clientAddress}</span>
|
||||
</Field>
|
||||
<Field label="GSTIN">{invoice.clientGstin}</Field>
|
||||
<Field label="PO number">{invoice.poNumber}</Field>
|
||||
<Field label="Place of supply">{invoice.placeOfSupplyStateCode ? stateName(invoice.placeOfSupplyStateCode) : ""}</Field>
|
||||
</dl>
|
||||
<dl>
|
||||
<Field label="Issued by">{vendor.vendorName}</Field>
|
||||
<Field label="Address">
|
||||
<span className="voiced-detail__pre">{vendor.vendorAddress}</span>
|
||||
</Field>
|
||||
<Field label="GSTIN">{vendor.vendorGstin}</Field>
|
||||
<Field label="PAN">{vendor.vendorPan}</Field>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="detail-items">
|
||||
<h2 id="detail-items" className="voiced-detail__h2">
|
||||
Items
|
||||
</h2>
|
||||
<TableContainer>
|
||||
<Table aria-label="Invoice items" size="sm">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Description</TableHeader>
|
||||
<TableHeader>HSN/SAC</TableHeader>
|
||||
<TableHeader className="voiced-num">Amount</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{invoice.items.map((item, i) => (
|
||||
<TableRow key={item.id ?? i}>
|
||||
<TableCell>
|
||||
<span className="voiced-detail__pre">{item.description}</span>
|
||||
{item.mode === "rate" ? (
|
||||
<div className="voiced-muted">
|
||||
{item.quantity} {item.unit} × ₹{formatAmount(item.rate)}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{item.hsnSac || "—"}</TableCell>
|
||||
<TableCell className="voiced-num">₹{formatAmount(item.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<dl className="voiced-detail__totals">
|
||||
<div>
|
||||
<dt>Subtotal</dt>
|
||||
<dd>₹{formatAmount(invoice.subtotal)}</dd>
|
||||
</div>
|
||||
{invoice.discount > 0 ? (
|
||||
<div>
|
||||
<dt>Discount</dt>
|
||||
<dd>−₹{formatAmount(invoice.discount)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{invoice.taxType === "cgst_sgst" ? (
|
||||
<>
|
||||
<div>
|
||||
<dt>CGST @ {half}%</dt>
|
||||
<dd>₹{formatAmount(invoice.cgstAmount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>SGST @ {half}%</dt>
|
||||
<dd>₹{formatAmount(invoice.sgstAmount)}</dd>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{invoice.taxType === "igst" ? (
|
||||
<div>
|
||||
<dt>IGST @ {invoice.taxRate}%</dt>
|
||||
<dd>₹{formatAmount(invoice.igstAmount)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="voiced-detail__grand">
|
||||
<dt>Total</dt>
|
||||
<dd>₹{formatAmount(invoice.total)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{invoice.amountInWords ? <p className="voiced-muted">{invoice.amountInWords}</p> : null}
|
||||
{invoice.notes ? <p className="voiced-detail__pre voiced-muted">{invoice.notes}</p> : null}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="detail-erpnext">
|
||||
<h2 id="detail-erpnext" className="voiced-detail__h2">
|
||||
ERPNext
|
||||
</h2>
|
||||
{!erpOn && !erpHasState ? (
|
||||
<p className="voiced-muted">
|
||||
ERPNext is not set up.{" "}
|
||||
<Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onOpenSettings();
|
||||
}}
|
||||
>
|
||||
Set it up in Settings, Integrations
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="voiced-erp__panel">
|
||||
<dl>
|
||||
<Field label="Status">
|
||||
<Tag type={erpTag.type} size="sm" title={erpTag.tooltip}>
|
||||
{erpTag.label}
|
||||
</Tag>
|
||||
</Field>
|
||||
{erpSync?.remoteName ? <Field label="ERPNext document">{erpSync.remoteName}</Field> : null}
|
||||
{erpSync?.syncedAt ? <Field label="Last synced">{formatSyncedAt(erpSync.syncedAt)}</Field> : null}
|
||||
{erpSync?.status === "synced" ? <Field label="PDF">{erpSync.attached ? "Attached in ERPNext" : "Not attached"}</Field> : null}
|
||||
</dl>
|
||||
{erpSync && (erpSync.status === "error" || erpSync.status === "conflict") && erpSync.lastError ? (
|
||||
<InlineNotification
|
||||
kind={erpSync.status === "conflict" ? "warning" : "error"}
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={erpSync.status === "conflict" ? "Conflict with ERPNext" : "The last send failed"}
|
||||
subtitle={erpSync.lastError}
|
||||
/>
|
||||
) : null}
|
||||
{lastPush && lastPush.warnings.length > 0 ? (
|
||||
<InlineNotification kind="warning" lowContrast hideCloseButton title="Sent with notes" subtitle={lastPush.warnings.join(" ")} />
|
||||
) : null}
|
||||
{erpOn ? (
|
||||
<>
|
||||
<div className="voiced-header-action">
|
||||
{erpSubmitted ? (
|
||||
<Button kind="tertiary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(true)}>
|
||||
{sendingErp ? "Sending…" : "Re-send"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button kind="tertiary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(false)}>
|
||||
{sendingErp ? "Sending…" : erpHasState ? "Re-send as draft" : "Send as draft"}
|
||||
</Button>
|
||||
<Button kind="primary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(true)}>
|
||||
Send & submit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canOpenInErpnext(erpSync) ? (
|
||||
<Button kind="ghost" size="sm" onClick={() => void onOpenErp()}>
|
||||
Open in ERPNext
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{erpBlocked ? (
|
||||
<p className="voiced-muted">
|
||||
{erpBlocked}{" "}
|
||||
<Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onOpenSettings();
|
||||
}}
|
||||
>
|
||||
Open Settings
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="voiced-muted">
|
||||
ERPNext is not set up any more, so nothing can be sent.{" "}
|
||||
<Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onOpenSettings();
|
||||
}}
|
||||
>
|
||||
Open Settings
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="detail-payments">
|
||||
<h2 id="detail-payments" className="voiced-detail__h2">
|
||||
Payments
|
||||
</h2>
|
||||
{payments.length === 0 ? (
|
||||
<p className="voiced-muted">No payments recorded.</p>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table aria-label="Payments" size="sm">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Date</TableHeader>
|
||||
<TableHeader>Mode</TableHeader>
|
||||
<TableHeader>Reference</TableHeader>
|
||||
<TableHeader className="voiced-num">Received</TableHeader>
|
||||
<TableHeader className="voiced-num">TDS</TableHeader>
|
||||
{showPaymentErp ? <TableHeader>ERPNext</TableHeader> : null}
|
||||
<TableHeader>
|
||||
<span className="cds--assistive-text">Actions</span>
|
||||
</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{payments.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{formatDate(p.paidOn)}</TableCell>
|
||||
<TableCell>{paymentModeLabel(p.mode)}</TableCell>
|
||||
<TableCell>
|
||||
{p.reference || "—"}
|
||||
{p.notes ? <div className="voiced-muted">{p.notes}</div> : null}
|
||||
</TableCell>
|
||||
<TableCell className="voiced-num">{formatPaise(p.amountPaise)}</TableCell>
|
||||
<TableCell className="voiced-num">{formatPaise(p.tdsPaise)}</TableCell>
|
||||
{showPaymentErp ? (
|
||||
<TableCell>
|
||||
{p.erpnextPaymentEntry ? (
|
||||
<>
|
||||
<Tag type="green" size="sm">
|
||||
Sent
|
||||
</Tag>{" "}
|
||||
<span className="voiced-muted">{p.erpnextPaymentEntry}</span>
|
||||
</>
|
||||
) : (
|
||||
(() => {
|
||||
const reason = paymentPushDisabledReason({ cfg: erpCfg, sync: erpSync, entry: p.erpnextPaymentEntry });
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
disabled={reason !== null || sendingPayment !== null}
|
||||
onClick={() => void onSendPayment(p.id)}
|
||||
>
|
||||
{sendingPayment === p.id ? "Sending…" : "Send payment to ERPNext"}
|
||||
</Button>
|
||||
{reason ? <div className="voiced-muted">{reason}</div> : null}
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
hasIconOnly
|
||||
renderIcon={TrashCan}
|
||||
iconDescription={`Delete payment of ${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`}
|
||||
tooltipPosition="left"
|
||||
onClick={() => setDeleteTarget(p)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="voiced-detail__preview" aria-label="Archived PDF">
|
||||
<h2 className="voiced-detail__h2">PDF as issued</h2>
|
||||
{archive && !archive.archived ? (
|
||||
<div className="voiced-detail__unarchived">
|
||||
<p>Issued – not archived. The original PDF was never saved, so there is nothing to preview yet.</p>
|
||||
<Button kind="primary" size="sm" onClick={() => void onArchive()} disabled={busy}>
|
||||
{archiving ? "Working…" : "Render & archive"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<PdfPreview bytes={pdf} pages={0} issues={[]} rendering={archive === null && !pdfError} mode="worker" error={pdfError} />
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<RecordPaymentModal
|
||||
open={recordOpen}
|
||||
invoiceId={invoice.id}
|
||||
invoiceNumber={invoice.number}
|
||||
balancePaise={balance}
|
||||
onClose={() => setRecordOpen(false)}
|
||||
onRecorded={(p) => {
|
||||
setRecordOpen(false);
|
||||
toast.success("Payment recorded", `${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={deleteTarget !== null}
|
||||
danger
|
||||
modalHeading="Delete this payment?"
|
||||
primaryButtonText="Delete payment"
|
||||
secondaryButtonText="Keep payment"
|
||||
onRequestClose={() => setDeleteTarget(null)}
|
||||
onRequestSubmit={() => void onDeletePayment()}
|
||||
>
|
||||
<p>
|
||||
{deleteTarget
|
||||
? `${formatPaise(deleteTarget.amountPaise + deleteTarget.tdsPaise)} (cash ${formatPaise(deleteTarget.amountPaise)}, TDS ${formatPaise(deleteTarget.tdsPaise)}) received on ${formatDate(deleteTarget.paidOn)}. The invoice balance goes back up by that amount.${deleteTarget.erpnextPaymentEntry ? ` It was sent to ERPNext as ${deleteTarget.erpnextPaymentEntry}; that entry is not removed there.` : ""}`
|
||||
: ""}
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={cancelOpen}
|
||||
danger
|
||||
modalHeading={`Cancel invoice ${invoice.number}?`}
|
||||
primaryButtonText="Cancel invoice"
|
||||
secondaryButtonText="Keep invoice"
|
||||
onRequestClose={() => setCancelOpen(false)}
|
||||
onRequestSubmit={() => void onCancelInvoice()}
|
||||
>
|
||||
<p style={{ marginBottom: "1rem" }}>The invoice stays in the list as cancelled and its number is not reused.</p>
|
||||
<TextInput id="detailCancelReason" labelText="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+456
-168
@@ -1,11 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
Modal,
|
||||
Link,
|
||||
OverflowMenu,
|
||||
OverflowMenuItem,
|
||||
Pagination,
|
||||
Search,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -14,126 +18,214 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tag,
|
||||
TextInput,
|
||||
Tile,
|
||||
} from "@carbon/react";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import { formatAmount, formatDate } from "../lib/format";
|
||||
import { createExportDeps } from "../lib/exportDeps";
|
||||
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
|
||||
import { exportHistory, type HistoryFormat } from "../lib/historyExport";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { formatPaise } from "../lib/money";
|
||||
import { statusBadge } from "../lib/payments";
|
||||
import { currentFyStartYear, FY_MONTHS, fyLabel } from "../lib/fiscal";
|
||||
import {
|
||||
clientOptions,
|
||||
filterInvoices,
|
||||
fyOptions,
|
||||
fySummary,
|
||||
NO_FILTERS,
|
||||
sortInvoices,
|
||||
STATUS_FILTERS,
|
||||
totalsOf,
|
||||
type HistoryFilters,
|
||||
type SortKey,
|
||||
type SortSpec,
|
||||
type StatusFilter,
|
||||
} from "../lib/historyFilter";
|
||||
import type { ExportMode } from "../lib/filename";
|
||||
import type { ExportSource } from "../lib/exportFlow";
|
||||
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||
import { useGenericExport } from "../hooks/useGenericExport";
|
||||
import { openInErpnext } from "../lib/erpnextActions";
|
||||
import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext";
|
||||
import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi";
|
||||
import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal";
|
||||
import ExportFeedback from "../components/ExportFeedback";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import type { InvoiceSummary, Settings } from "../lib/types";
|
||||
|
||||
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
|
||||
interface Props {
|
||||
settings: Settings;
|
||||
active: boolean;
|
||||
/** Opens the detail page of an invoice. */
|
||||
onOpen: (id: number) => void;
|
||||
/** Goes to Settings, Integrations. */
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
const COLUMNS: { key: SortKey; label: string; numeric?: boolean }[] = [
|
||||
{ key: "number", label: "Number" },
|
||||
{ key: "date", label: "Date" },
|
||||
{ key: "client", label: "Client" },
|
||||
{ key: "total", label: "Total", numeric: true },
|
||||
{ key: "balance", label: "Balance", numeric: true },
|
||||
{ key: "status", label: "Status" },
|
||||
];
|
||||
|
||||
export default function InvoiceHistory({ settings, active, onOpen, onOpenSettings }: Props) {
|
||||
const toast = useToast();
|
||||
const [rows, setRows] = useState<InvoiceSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
|
||||
const [archived, setArchived] = useState<Record<number, boolean>>({});
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const exp = useInvoiceExport({
|
||||
settings,
|
||||
bank: null,
|
||||
onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })),
|
||||
});
|
||||
const [filters, setFilters] = useState<HistoryFilters>(NO_FILTERS);
|
||||
const [sort, setSort] = useState<SortSpec>({ key: "date", dir: "desc" });
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
|
||||
const exp = useInvoiceExport({ settings, bank: null });
|
||||
const [exportingHistory, setExportingHistory] = useState(false);
|
||||
const [erpCfg, setErpCfg] = useState<ErpnextConfig | null>(null);
|
||||
const [syncs, setSyncs] = useState<Map<number, ErpnextSyncStatus>>(new Map());
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [sendTargets, setSendTargets] = useState<{ invoices: SendTarget[]; skipped: number } | null>(null);
|
||||
const generic = useGenericExport();
|
||||
|
||||
const load = async () => {
|
||||
/** One bulk call for every row's ERPNext status. ERPNext is optional, so a failure here only hides the column. */
|
||||
const loadErpnext = async () => {
|
||||
try {
|
||||
setRows(await api.listInvoices());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const [cfg, list] = await Promise.all([api.erpnextGetConfig(), api.erpnextSyncStatuses()]);
|
||||
setErpCfg(cfg);
|
||||
setSyncs(indexSyncStatuses(list));
|
||||
} catch {
|
||||
setErpCfg(null);
|
||||
setSyncs(new Map());
|
||||
}
|
||||
};
|
||||
|
||||
// The view stays mounted, so refetch every time it is shown again.
|
||||
const load = async () => {
|
||||
try {
|
||||
const list = await api.listInvoices();
|
||||
setRows(list);
|
||||
// Keep only selections that still exist.
|
||||
setSelected((prev) => new Set([...prev].filter((id) => list.some((r) => r.id === id))));
|
||||
} catch (e) {
|
||||
toast.error("Could not load invoices", String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
void loadErpnext();
|
||||
};
|
||||
|
||||
// The view stays mounted, so refetch every time it is shown again (a payment may have been recorded).
|
||||
useEffect(() => {
|
||||
if (active) void load();
|
||||
}, [active]);
|
||||
|
||||
// Read each invoice's archive status in the background; it is a local call per row.
|
||||
useEffect(() => {
|
||||
let stop = false;
|
||||
void (async () => {
|
||||
for (const row of rows) {
|
||||
if (stop) return;
|
||||
try {
|
||||
const status = await api.archiveStatus(row.id);
|
||||
if (!stop) setArchived((a) => ({ ...a, [row.id]: status.archived }));
|
||||
} catch {
|
||||
// Unknown stays unknown: the row just shows no tag.
|
||||
}
|
||||
const patch = (change: Partial<HistoryFilters>) => {
|
||||
setFilters((f) => ({ ...f, ...change }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const currentFy = currentFyStartYear();
|
||||
const tileFy = filters.fy === "all" ? currentFy : filters.fy;
|
||||
const filtered = useMemo(() => filterInvoices(rows, filters), [rows, filters]);
|
||||
const sorted = useMemo(() => sortInvoices(filtered, sort), [filtered, sort]);
|
||||
const totals = useMemo(() => totalsOf(filtered), [filtered]);
|
||||
const summary = useMemo(() => fySummary(rows, tileFy), [rows, tileFy]);
|
||||
const years = useMemo(() => fyOptions(rows, currentFy), [rows, currentFy]);
|
||||
const clients = useMemo(() => clientOptions(rows), [rows]);
|
||||
const filtersActive = JSON.stringify(filters) !== JSON.stringify(NO_FILTERS);
|
||||
|
||||
const lastPage = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const shown = sorted.slice((Math.min(page, lastPage) - 1) * pageSize, Math.min(page, lastPage) * pageSize);
|
||||
|
||||
const onSort = (key: SortKey) =>
|
||||
setSort((s) => (s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: key === "date" || key === "total" || key === "balance" ? "desc" : "asc" }));
|
||||
|
||||
const erpOn = isConfigured(erpCfg);
|
||||
const vendorRegistered = settings.gstRegistration !== "unregistered";
|
||||
const showErpColumn = erpOn || syncs.size > 0;
|
||||
const selectedRows = rows.filter((r) => selected.has(r.id));
|
||||
const blockedReason = pushDisabledReason({ cfg: erpCfg, vendorRegistered });
|
||||
const pageAllSelected = shown.length > 0 && shown.every((r) => selected.has(r.id));
|
||||
const toggleOne = (id: number, on: boolean) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
const togglePage = (on: boolean) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const r of shown) {
|
||||
if (on) next.add(r.id);
|
||||
else next.delete(r.id);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
stop = true;
|
||||
};
|
||||
}, [rows]);
|
||||
return next;
|
||||
});
|
||||
const askSend = (list: InvoiceSummary[]) => {
|
||||
const { send, skipped } = sendableSelection(list);
|
||||
if (send.length === 0) {
|
||||
toast.warning("Nothing to send", "Only issued invoices are sent to ERPNext.");
|
||||
return;
|
||||
}
|
||||
setSendTargets({ invoices: send.map((r) => ({ id: r.id, number: r.number })), skipped });
|
||||
};
|
||||
const onOpenErpnext = async (id: number) => {
|
||||
try {
|
||||
await openInErpnext(id);
|
||||
} catch (e) {
|
||||
toast.error("Could not open ERPNext", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
// Fetched now, not at list time: the row summary does not carry the archive fields.
|
||||
const invoice = await api.getInvoice(id);
|
||||
await exp.start(invoice, mode, source);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Export failed", String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onArchive = async (id: number) => {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
/** Exactly the rows the table is filtered to (every page, in the current sort order). */
|
||||
const onExportHistory = async (format: HistoryFormat) => {
|
||||
setExportingHistory(true);
|
||||
try {
|
||||
const invoice = await api.getInvoice(id);
|
||||
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
|
||||
if (outcome.archived) {
|
||||
setArchived((a) => ({ ...a, [id]: true }));
|
||||
setNotice(`Invoice ${invoice.number} is archived.`);
|
||||
} else {
|
||||
setError(`Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
|
||||
}
|
||||
const done = await exportHistory(
|
||||
{
|
||||
listLedger: api.listInvoiceLedger,
|
||||
getLastExportDir: api.getLastExportDir,
|
||||
save: ({ defaultPath, format: fmt }) =>
|
||||
save({
|
||||
defaultPath,
|
||||
filters: [fmt === "csv" ? { name: "CSV (Excel)", extensions: ["csv"] } : { name: "JSON", extensions: ["json"] }],
|
||||
}),
|
||||
writeExportFile: api.writeExportFile,
|
||||
now: () => new Date(),
|
||||
},
|
||||
sorted,
|
||||
filters,
|
||||
format,
|
||||
);
|
||||
if (done) toast.success(`Exported ${done.count} invoices`, done.path);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not export the invoices", String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onCancel = async () => {
|
||||
if (!cancelTarget) return;
|
||||
try {
|
||||
await api.cancelInvoice(cancelTarget.id, reason);
|
||||
setCancelTarget(null);
|
||||
setReason("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setCancelTarget(null);
|
||||
setError(String(e));
|
||||
setExportingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Invoices</h1>
|
||||
<p className="voiced-page__subtitle">Every invoice you have issued, newest first.</p>
|
||||
<p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p>
|
||||
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||
) : null}
|
||||
|
||||
{notice ? <InlineNotification kind="success" lowContrast title={notice} onCloseButtonClick={() => setNotice(null)} /> : null}
|
||||
<ExportFeedback exp={exp} />
|
||||
|
||||
{loading ? (
|
||||
@@ -141,98 +233,294 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
) : rows.length === 0 ? (
|
||||
<div className="voiced-empty">No invoices yet. Create one from “New invoice”.</div>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table aria-label="Invoices">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Number</TableHeader>
|
||||
<TableHeader>Date</TableHeader>
|
||||
<TableHeader>Client</TableHeader>
|
||||
<TableHeader>Total</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader>Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.number}</TableCell>
|
||||
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
|
||||
<TableCell>{row.clientName || "—"}</TableCell>
|
||||
<TableCell>₹{formatAmount(row.total)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
{row.status === "cancelled" ? (
|
||||
<Tag type="red" size="sm">
|
||||
Cancelled
|
||||
</Tag>
|
||||
) : (
|
||||
row.status
|
||||
)}
|
||||
{archived[row.id] === false ? (
|
||||
<Tag type="warm-gray" size="sm">
|
||||
Not archived
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
{archived[row.id] === false ? (
|
||||
<Button kind="ghost" size="sm" onClick={() => void onArchive(row.id)} disabled={busyId !== null || exp.busy}>
|
||||
{busyId === row.id ? "Working…" : "Render & archive"}
|
||||
</Button>
|
||||
) : null}
|
||||
<OverflowMenu
|
||||
aria-label={`Actions for invoice ${row.number}`}
|
||||
iconDescription="Actions"
|
||||
flipped
|
||||
size="sm"
|
||||
disabled={busyId !== null || exp.busy}
|
||||
>
|
||||
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
|
||||
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
|
||||
<OverflowMenuItem itemText="Re-render with current template" onClick={() => void onExport(row.id, "searchable", "rerender")} />
|
||||
{row.status === "issued" ? (
|
||||
<OverflowMenuItem
|
||||
itemText="Cancel invoice"
|
||||
hasDivider
|
||||
isDelete
|
||||
onClick={() => {
|
||||
setReason("");
|
||||
setCancelTarget(row);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<>
|
||||
<section aria-label={`Summary for ${fyLabel(tileFy)}`}>
|
||||
<h2 className="voiced-history__fy">{fyLabel(tileFy)}</h2>
|
||||
<div className="voiced-history__tiles">
|
||||
<Tile>
|
||||
<p className="voiced-muted">Invoiced</p>
|
||||
<p className="voiced-history__amount">{formatPaise(summary.invoicedPaise)}</p>
|
||||
<p className="voiced-muted">{summary.count} invoices</p>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<p className="voiced-muted">Received</p>
|
||||
<p className="voiced-history__amount">{formatPaise(summary.receivedPaise)}</p>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<p className="voiced-muted">TDS deducted</p>
|
||||
<p className="voiced-history__amount">{formatPaise(summary.tdsPaise)}</p>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<p className="voiced-muted">Outstanding</p>
|
||||
<p className="voiced-history__amount">{formatPaise(summary.outstandingPaise)}</p>
|
||||
</Tile>
|
||||
<Tile className={summary.overdueCount > 0 ? "voiced-history__tile--overdue" : undefined}>
|
||||
<p className="voiced-muted">Overdue</p>
|
||||
<p className="voiced-history__amount">{formatPaise(summary.overduePaise)}</p>
|
||||
<p className="voiced-muted">{summary.overdueCount} invoices</p>
|
||||
</Tile>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="voiced-history__filters" role="search">
|
||||
<Search
|
||||
id="historySearch"
|
||||
size="md"
|
||||
labelText="Search invoices"
|
||||
placeholder="Number, client or PO number"
|
||||
value={filters.search}
|
||||
onChange={(e) => patch({ search: e.target.value })}
|
||||
onClear={() => patch({ search: "" })}
|
||||
/>
|
||||
<Select id="historyStatus" size="md" labelText="Status" value={filters.status} onChange={(e) => patch({ status: e.target.value as StatusFilter })}>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id} text={s.label} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Select>
|
||||
<Select
|
||||
id="historyFy"
|
||||
size="md"
|
||||
labelText="Financial year"
|
||||
value={String(filters.fy)}
|
||||
onChange={(e) => patch({ fy: e.target.value === "all" ? "all" : Number(e.target.value) })}
|
||||
>
|
||||
<SelectItem value="all" text="All years" />
|
||||
{years.map((y) => (
|
||||
<SelectItem key={y} value={String(y)} text={fyLabel(y)} />
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
id="historyMonth"
|
||||
size="md"
|
||||
labelText="Month"
|
||||
value={String(filters.month)}
|
||||
onChange={(e) => patch({ month: e.target.value === "all" ? "all" : Number(e.target.value) })}
|
||||
>
|
||||
<SelectItem value="all" text="All months" />
|
||||
{FY_MONTHS.map((m) => (
|
||||
<SelectItem key={m.month} value={String(m.month)} text={m.label} />
|
||||
))}
|
||||
</Select>
|
||||
<Select id="historyClient" size="md" labelText="Client" value={filters.client} onChange={(e) => patch({ client: e.target.value })}>
|
||||
<SelectItem value="all" text="All clients" />
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c} value={c} text={c} />
|
||||
))}
|
||||
</Select>
|
||||
{filtersActive ? (
|
||||
<Button kind="ghost" size="md" onClick={() => patch({ ...NO_FILTERS })}>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="voiced-history__export">
|
||||
<Button kind="tertiary" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("csv")}>
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button kind="ghost" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("json")}>
|
||||
Export JSON
|
||||
</Button>
|
||||
<span className="voiced-muted">
|
||||
{filtersActive ? `The ${sorted.length} filtered invoices` : `All ${sorted.length} invoices`}, for your accountant
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="voiced-history__bulk" role="group" aria-label="Actions for the selected invoices">
|
||||
<span aria-live="polite">{selected.size} selected</span>
|
||||
<Button kind="tertiary" size="md" disabled={selected.size === 0 || blockedReason !== null} onClick={() => askSend(selectedRows)}>
|
||||
Send selected to ERPNext
|
||||
</Button>
|
||||
<Button kind="ghost" size="md" disabled={selected.size === 0 || generic.busy} onClick={() => void generic.run([...selected], "json")}>
|
||||
Export selected (voiced.invoice.v1 JSON)
|
||||
</Button>
|
||||
<Button kind="ghost" size="md" disabled={selected.size === 0 || generic.busy} onClick={() => void generic.run([...selected], "csv")}>
|
||||
Export selected (line items CSV)
|
||||
</Button>
|
||||
{selected.size > 0 ? (
|
||||
<Button kind="ghost" size="md" onClick={() => setSelected(new Set())}>
|
||||
Clear selection
|
||||
</Button>
|
||||
) : null}
|
||||
{blockedReason !== null && selected.size > 0 ? (
|
||||
<span className="voiced-muted">
|
||||
{blockedReason}{" "}
|
||||
<Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onOpenSettings();
|
||||
}}
|
||||
>
|
||||
Open Settings
|
||||
</Link>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<div className="voiced-empty">No invoices match these filters.</div>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table aria-label="Invoices">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>
|
||||
<Checkbox id="historySelectPage" labelText="Select all invoices on this page" hideLabel checked={pageAllSelected} onChange={(_e, { checked }) => togglePage(checked)} />
|
||||
</TableHeader>
|
||||
{COLUMNS.map((c) => (
|
||||
<TableHeader
|
||||
key={c.key}
|
||||
isSortable
|
||||
isSortHeader={sort.key === c.key}
|
||||
sortDirection={sort.key === c.key ? (sort.dir === "asc" ? "ASC" : "DESC") : "NONE"}
|
||||
onClick={() => onSort(c.key)}
|
||||
className={c.numeric ? "voiced-num" : undefined}
|
||||
>
|
||||
{c.label}
|
||||
</TableHeader>
|
||||
))}
|
||||
{showErpColumn ? <TableHeader>ERPNext</TableHeader> : null}
|
||||
<TableHeader>
|
||||
<span className="cds--assistive-text">Actions</span>
|
||||
</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{shown.map((row) => {
|
||||
const badge = statusBadge(row.status, row.paymentStatus);
|
||||
const sync = syncs.get(row.id);
|
||||
const erpTag = syncTag(sync);
|
||||
const rowBlocked = pushDisabledReason({ cfg: erpCfg, vendorRegistered, invoiceStatus: row.status });
|
||||
return (
|
||||
<TableRow key={row.id} className="voiced-history__row" onClick={() => onOpen(row.id)}>
|
||||
<TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
id={`historySelect-${row.id}`}
|
||||
labelText={`Select invoice ${row.number}`}
|
||||
hideLabel
|
||||
checked={selected.has(row.id)}
|
||||
onChange={(_e, { checked }) => toggleOne(row.id, checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpen(row.id);
|
||||
}}
|
||||
>
|
||||
{row.number}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
|
||||
<TableCell>{row.clientName || "—"}</TableCell>
|
||||
<TableCell className="voiced-num">{formatPaise(row.totalPaise)}</TableCell>
|
||||
<TableCell className="voiced-num">{row.status === "cancelled" ? "—" : formatPaise(row.balancePaise)}</TableCell>
|
||||
<TableCell>
|
||||
<Tag type={badge.type} size="sm">
|
||||
{badge.label}
|
||||
</Tag>
|
||||
{row.status === "issued" && row.paymentStatus === "overdue" && row.paidPaise + row.tdsPaise > 0 ? (
|
||||
<Tag type="blue" size="sm">
|
||||
Partly paid
|
||||
</Tag>
|
||||
) : null}
|
||||
</TableCell>
|
||||
{showErpColumn ? (
|
||||
<TableCell>
|
||||
<Tag type={erpTag.type} size="sm" title={erpTag.tooltip}>
|
||||
{erpTag.label}
|
||||
</Tag>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<OverflowMenu
|
||||
aria-label={`Actions for invoice ${row.number}`}
|
||||
iconDescription="Actions"
|
||||
flipped
|
||||
size="sm"
|
||||
disabled={busyId !== null || exp.busy}
|
||||
>
|
||||
<OverflowMenuItem itemText="Open" onClick={() => onOpen(row.id)} />
|
||||
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
|
||||
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
|
||||
<OverflowMenuItem itemText="Export as voiced.invoice.v1 (JSON)" hasDivider onClick={() => void generic.run([row.id], "json")} />
|
||||
{erpOn || syncs.size > 0 ? (
|
||||
<OverflowMenuItem
|
||||
itemText="Send to ERPNext"
|
||||
hasDivider
|
||||
disabled={rowBlocked !== null}
|
||||
title={rowBlocked ?? undefined}
|
||||
onClick={() => askSend([row])}
|
||||
/>
|
||||
) : null}
|
||||
{canOpenInErpnext(sync) && erpOn ? <OverflowMenuItem itemText="Open in ERPNext" onClick={() => void onOpenErpnext(row.id)} /> : null}
|
||||
</OverflowMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="voiced-history__totals" role="status" aria-label="Totals for the filtered invoices">
|
||||
<span className="voiced-muted">
|
||||
{filtersActive ? "Filtered" : "All"}: {totals.count} invoices
|
||||
</span>
|
||||
<span>
|
||||
Invoiced <strong>{formatPaise(totals.invoicedPaise)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Received <strong>{formatPaise(totals.receivedPaise)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
TDS <strong>{formatPaise(totals.tdsPaise)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Outstanding <strong>{formatPaise(totals.outstandingPaise)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<Pagination
|
||||
page={Math.min(page, lastPage)}
|
||||
pageSize={pageSize}
|
||||
pageSizes={PAGE_SIZES}
|
||||
totalItems={sorted.length}
|
||||
onChange={({ page: p, pageSize: s }: { page: number; pageSize: number }) => {
|
||||
setPage(s !== pageSize ? 1 : p);
|
||||
setPageSize(s);
|
||||
}}
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={cancelTarget !== null}
|
||||
danger
|
||||
modalHeading={`Cancel invoice ${cancelTarget?.number ?? ""}?`}
|
||||
primaryButtonText="Cancel invoice"
|
||||
secondaryButtonText="Keep invoice"
|
||||
onRequestClose={() => setCancelTarget(null)}
|
||||
onRequestSubmit={onCancel}
|
||||
>
|
||||
<p style={{ marginBottom: "1rem" }}>
|
||||
The invoice stays in the list as cancelled and its number is not reused.
|
||||
{!erpOn && !loading && rows.length > 0 ? (
|
||||
<p className="voiced-muted" style={{ marginTop: "1rem" }}>
|
||||
ERPNext is not set up.{" "}
|
||||
<Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onOpenSettings();
|
||||
}}
|
||||
>
|
||||
Set it up in Settings, Integrations
|
||||
</Link>{" "}
|
||||
to send invoices to it.
|
||||
</p>
|
||||
<TextInput
|
||||
id="cancelReason"
|
||||
labelText="Reason (optional)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
<SendToErpnextModal
|
||||
open={sendTargets !== null}
|
||||
invoices={sendTargets?.invoices ?? []}
|
||||
skipped={sendTargets?.skipped ?? 0}
|
||||
defaultSubmit={erpCfg?.submitOnPush ?? false}
|
||||
onClose={() => setSendTargets(null)}
|
||||
onFinished={() => void loadErpnext()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+102
-19
@@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro
|
||||
import {
|
||||
ActionableNotification,
|
||||
Button,
|
||||
ComboBox,
|
||||
Column,
|
||||
Dropdown,
|
||||
Grid,
|
||||
@@ -37,6 +38,7 @@ import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
|
||||
import type {
|
||||
BankAccount,
|
||||
Client,
|
||||
ItemPreset,
|
||||
DraftSummary,
|
||||
Invoice,
|
||||
InvoiceInput,
|
||||
@@ -57,6 +59,7 @@ import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
|
||||
import PageSetupControls from "../components/PageSetupControls";
|
||||
import { useInvoicePdf } from "../hooks/useInvoicePdf";
|
||||
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||
import { autoPushIssued } from "../lib/erpnextUi";
|
||||
import ExportButton, { ExportModePicker } from "../components/ExportButton";
|
||||
import ExportFeedback from "../components/ExportFeedback";
|
||||
import { createExportDeps } from "../lib/exportDeps";
|
||||
@@ -68,6 +71,9 @@ import { useUserFonts } from "../hooks/useUserFonts";
|
||||
import { withUserFonts } from "../lib/fontImport";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
import { loadLogoImages, type LogoImages } from "../lib/logo";
|
||||
import ClientModal from "../components/ClientModal";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import { itemFromPreset } from "../lib/presets";
|
||||
|
||||
// pdf.js is only needed once a preview exists, so it loads on demand.
|
||||
const PdfPreview = lazy(() => import("../components/PdfPreview"));
|
||||
@@ -79,6 +85,9 @@ interface NewInvoiceProps {
|
||||
active: boolean;
|
||||
/** Ask the shell to show this view (used by Ctrl+N from another tab). */
|
||||
onActivate: () => void;
|
||||
/** A draft to open as soon as the form is shown (set by "Duplicate" on the invoice page). */
|
||||
openDraftId?: number | null;
|
||||
onDraftOpened?: () => void;
|
||||
}
|
||||
|
||||
const AUTOSAVE_MS = 1000;
|
||||
@@ -89,7 +98,8 @@ const formatStamp = (iso: string) => {
|
||||
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
||||
};
|
||||
|
||||
export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) {
|
||||
export default function NewInvoice({ settings, onSettingsChange, active, onActivate, openDraftId = null, onDraftOpened }: NewInvoiceProps) {
|
||||
const toast = useToast();
|
||||
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
||||
const blankState = (): InvoiceFormState => {
|
||||
const t = todayIso();
|
||||
@@ -118,6 +128,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
const [number, setNumber] = useState("");
|
||||
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
||||
const [clientModalOpen, setClientModalOpen] = useState(false);
|
||||
const newClientButton = useRef<HTMLButtonElement | null>(null);
|
||||
const [clientId, setClientId] = useState<number | null>(null);
|
||||
const [clientName, setClientName] = useState("");
|
||||
const [clientAddress, setClientAddress] = useState("");
|
||||
@@ -188,7 +201,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
setDrafts(draftList);
|
||||
setNoticeOpen(draftList.length > 0);
|
||||
} catch (e) {
|
||||
if (active) setError(String(e));
|
||||
if (active) toast.error("Could not load invoice data", String(e));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -196,6 +209,13 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The view stays mounted, so pick up clients and presets edited on the Clients page when it is shown again.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
api.listClients().then(setClients).catch(() => {});
|
||||
api.listItemPresets().then(setPresets).catch(() => {});
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!settings.logoPath) {
|
||||
@@ -333,6 +353,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
// A failed autosave is reported once per failure streak (the message clears on the next success).
|
||||
useEffect(() => {
|
||||
if (draftError) toast.warning("Draft not saved", draftError);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draftError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!unsaved) return;
|
||||
const timer = setTimeout(() => {
|
||||
@@ -390,6 +416,15 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
setClientGstin(selected.gstin);
|
||||
setPoNumber(selected.poNumber);
|
||||
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
|
||||
// Client defaults fill gaps only: typed notes are never overwritten.
|
||||
if (selected.defaultNotes && !notes.trim()) setNotes(selected.defaultNotes);
|
||||
if (selected.paymentTermsDays !== null) setDueDate(addDays(invoiceDate, selected.paymentTermsDays));
|
||||
};
|
||||
|
||||
const addPreset = (preset: ItemPreset) => {
|
||||
const line = itemFromPreset(preset, { hsnSac: settings.defaultHsnSac });
|
||||
const isBlank = (i: InvoiceItem) => !i.description.trim() && !i.amount && !i.rate;
|
||||
setItems((prev) => (prev.length === 1 && isBlank(prev[0]) ? [line] : [...prev, line]));
|
||||
};
|
||||
|
||||
const applyState = (s: InvoiceFormState) => {
|
||||
@@ -527,15 +562,23 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
void refreshNumber();
|
||||
void refreshDrafts();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not open the draft", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (openDraftId === null || !active) return;
|
||||
onDraftOpened?.();
|
||||
void resumeDraft(openDraftId);
|
||||
// resumeDraft closes over fresh state each render; only a new request should trigger it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openDraftId, active]);
|
||||
|
||||
const removeDraft = async (id: number) => {
|
||||
try {
|
||||
await api.deleteDraft(id);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not delete the draft", String(e));
|
||||
}
|
||||
await refreshDrafts();
|
||||
};
|
||||
@@ -596,7 +639,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
|
||||
} catch (e) {
|
||||
issuedRef.current = false;
|
||||
setError(String(e));
|
||||
toast.error("Could not issue the invoice", String(e));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
@@ -607,6 +650,16 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
detachDraft();
|
||||
api.listClients().then(setClients).catch(() => {});
|
||||
void refreshDrafts();
|
||||
// "Send on issue": in the background, so it never delays or fails the issue; the result arrives as a toast.
|
||||
void autoPushIssued(
|
||||
{ getConfig: api.erpnextGetConfig, push: api.erpnextPushInvoice },
|
||||
outcome.invoice,
|
||||
settings.gstRegistration !== "unregistered",
|
||||
)
|
||||
.then((note) => {
|
||||
if (note) toast[note.kind](note.title, note.subtitle);
|
||||
})
|
||||
.catch(() => {});
|
||||
// Unarchived invoices export by rendering and archiving first (see exportInvoice), so this still works.
|
||||
await exp.start(outcome.invoice, mode, "original");
|
||||
setSaving(false);
|
||||
@@ -681,20 +734,11 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
<InlineNotification
|
||||
kind="error"
|
||||
lowContrast
|
||||
title="Could not save invoice"
|
||||
title="Fix this before issuing"
|
||||
subtitle={error}
|
||||
onCloseButtonClick={() => setError(null)}
|
||||
/>
|
||||
) : null}
|
||||
{draftError ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
lowContrast
|
||||
title="Draft not saved"
|
||||
subtitle={draftError}
|
||||
onCloseButtonClick={() => setDraftError(null)}
|
||||
/>
|
||||
) : null}
|
||||
{!locked && noticeOpen && !dirty && otherDrafts.length > 0 ? (
|
||||
<ActionableNotification
|
||||
inline
|
||||
@@ -766,16 +810,30 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
</Column>
|
||||
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<Dropdown
|
||||
<ComboBox
|
||||
id="savedClient"
|
||||
disabled={locked}
|
||||
titleText="Saved client (optional)"
|
||||
label="Select a saved client to autofill"
|
||||
placeholder="Type to search saved clients"
|
||||
items={clients}
|
||||
itemToString={(item) => (item ? item.name : "")}
|
||||
itemToString={(item: Client | null) => (item ? item.name : "")}
|
||||
selectedItem={clients.find((c) => c.id === clientId) ?? null}
|
||||
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
|
||||
shouldFilterItem={({ item, inputValue }: { item: Client; inputValue: string | null }) => {
|
||||
const q = (inputValue ?? "").trim().toLowerCase();
|
||||
return !q || item.name.toLowerCase().includes(q) || item.gstin.toLowerCase().includes(q);
|
||||
}}
|
||||
onChange={({ selectedItem }: { selectedItem?: Client | null }) => selectedItem && applyClient(selectedItem)}
|
||||
/>
|
||||
<Button
|
||||
ref={newClientButton}
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Add}
|
||||
disabled={locked}
|
||||
onClick={() => setClientModalOpen(true)}
|
||||
>
|
||||
Create client
|
||||
</Button>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<TextInput
|
||||
@@ -845,6 +903,20 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
|
||||
<div>
|
||||
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
|
||||
{presets.length > 0 ? (
|
||||
<div style={{ maxWidth: "24rem", marginBottom: "0.75rem" }}>
|
||||
<Dropdown
|
||||
id="addFromPreset"
|
||||
disabled={locked}
|
||||
titleText="Add from preset"
|
||||
label="Choose a saved item"
|
||||
items={presets}
|
||||
itemToString={(item: ItemPreset | null) => (item ? item.description : "")}
|
||||
selectedItem={null}
|
||||
onChange={({ selectedItem }: { selectedItem?: ItemPreset | null }) => selectedItem && addPreset(selectedItem)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
@@ -1134,6 +1206,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
<ClientModal
|
||||
open={clientModalOpen}
|
||||
client={null}
|
||||
launcherRef={newClientButton}
|
||||
onClose={() => setClientModalOpen(false)}
|
||||
onSaved={(saved) => {
|
||||
setClientModalOpen(false);
|
||||
applyClient(saved);
|
||||
api.listClients().then(setClients).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Column,
|
||||
Grid,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
NumberInput,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,8 +18,17 @@ import {
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import type { InvoiceSeries } from "../lib/types";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
|
||||
export default function SeriesSettings({ active: visible }: { active: boolean }) {
|
||||
export default function SeriesSettings({
|
||||
active: visible,
|
||||
embedded = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
/** Rendered inside Settings: no page wrapper or title. */
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [active, setActive] = useState<InvoiceSeries | null>(null);
|
||||
const [all, setAll] = useState<InvoiceSeries[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -28,8 +36,6 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
|
||||
const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`);
|
||||
const [padding, setPadding] = useState(3);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const seeded = useRef(false);
|
||||
|
||||
@@ -44,9 +50,8 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
|
||||
setPadding(current.padding);
|
||||
seeded.current = true;
|
||||
}
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not load series", String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -65,47 +70,34 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
|
||||
);
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const created = await api.startNewSeries(prefix, padding);
|
||||
setNotice(`New series started. Next invoice: ${created.nextInvoiceNumber}`);
|
||||
toast.success("New series started", `Next invoice: ${created.nextInvoiceNumber}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not start a new series", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pageClass = embedded ? undefined : "voiced-page";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<div className={pageClass}>
|
||||
<InlineLoading description="Loading series" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Invoice series</h1>
|
||||
<p className="voiced-page__subtitle">
|
||||
<div className={pageClass}>
|
||||
{embedded ? null : <h1 className="voiced-page__title">Invoice series</h1>}
|
||||
<p className={embedded ? "voiced-muted" : "voiced-page__subtitle"}>
|
||||
The active series drives the automatic invoice numbers. Start a new series to reset the counter.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||
) : null}
|
||||
{notice ? (
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
title="Done"
|
||||
subtitle={notice}
|
||||
onCloseButtonClick={() => setNotice(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Grid narrow style={{ marginBottom: "2rem" }}>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
|
||||
Reference in New Issue
Block a user