Verify the ERPNext integration live and fix the defects it found (Phase F4)
Ran the push flow against ERPNext 15.121.6 on a plain site and on a site with India Compliance 15.32.0 (podman, scripts/erpnext-e2e). 15 ignored live tests pass on both. Fixes, each covered by a mock-based test: - load_options no longer fails on a plain site: the India Compliance-only gstin field is dropped on a 417 and retried. - Code-less rows with fractional hours or minutes send stock_uom, since ERPNext defaults it to Nos. - A created document whose total differs from Voiced's (ERPNext's default Banker's Rounding on half-paise ties) is recorded as a conflict, kept as a draft, not submitted and not given a PDF. The message names Commercial Rounding as the fix. - An existing customer address is reused instead of creating a duplicate on every first push. A bare creation order-by is not used with a Dynamic Link filter. - A re-adopted document no longer gets a second copy of the PDF. - Reverse charge: India Compliance rejects is_reverse_charge unless tax rows are negative RCM amounts, so the flag is sent as 0 with a warning that the invoice lands as a normal taxed invoice. - The 16-character name rule is checked locally in mirror mode under India Compliance, and a TDS-only payment is refused locally with the reason. Payment Entry mapping verified: bank_account is the Account name (paid_to), paid and received amounts equal the cash received, allocated_amount is cash plus TDS, and TDS is one positive deductions row. The integration user needs the Accounts User and Sales User roles. Not verified: ERPNext v14 and v16, other India Compliance versions, the Tauri commands and UI against a live site, SEZ and overseas customers, UTGST supplier states, TLS sites, e-invoicing.
This commit is contained in:
@@ -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()
|
||||||
@@ -286,18 +286,19 @@ impl ErpClient {
|
|||||||
return Ok(out);
|
return Ok(out);
|
||||||
}
|
}
|
||||||
load!("companyAddresses", out.company_addresses, async {
|
load!("companyAddresses", out.company_addresses, async {
|
||||||
self.list_resource(
|
let filters = json!([
|
||||||
"Address",
|
|
||||||
&["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"],
|
|
||||||
json!([
|
|
||||||
["Dynamic Link", "link_doctype", "=", "Company"],
|
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||||
["Dynamic Link", "link_name", "=", company],
|
["Dynamic Link", "link_name", "=", company],
|
||||||
["disabled", "=", 0]
|
["disabled", "=", 0]
|
||||||
]),
|
]);
|
||||||
"name asc",
|
let base = ["name", "address_title", "address_line1", "city", "state", "pincode"];
|
||||||
)
|
let with_gstin = ["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"];
|
||||||
.await
|
// `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted").
|
||||||
.map(|rows| {
|
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()
|
rows.iter()
|
||||||
.filter_map(|r| {
|
.filter_map(|r| {
|
||||||
item(
|
item(
|
||||||
@@ -306,8 +307,8 @@ impl ErpClient {
|
|||||||
join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]),
|
join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect(),
|
||||||
})
|
)
|
||||||
});
|
});
|
||||||
load!("incomeAccounts", out.income_accounts, async {
|
load!("incomeAccounts", out.income_accounts, async {
|
||||||
self.list_resource(
|
self.list_resource(
|
||||||
@@ -792,6 +793,26 @@ mod tests {
|
|||||||
assert_eq!(json["features"]["v2Naming"], 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": "Arun Co-Billing", "address_title": "Arun Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001" }] }))
|
||||||
|
} else {
|
||||||
|
reply(200, json!({ "data": [] }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let options = client(&mock).load_options("Arun Co").await.unwrap();
|
||||||
|
assert_eq!(options.company_addresses.len(), 1);
|
||||||
|
assert_eq!(options.company_addresses[0].name, "Arun Co-Billing");
|
||||||
|
assert!(options.errors.iter().all(|e| e.list != "companyAddresses"), "{:?}", options.errors);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
|
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
|
||||||
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;
|
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
@@ -258,6 +258,7 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
|
|||||||
.filter(|c| !c.is_empty())
|
.filter(|c| !c.is_empty())
|
||||||
.or_else(|| Some(cfg.default_item_code.trim()).filter(|c| !c.is_empty()));
|
.or_else(|| Some(cfg.default_item_code.trim()).filter(|c| !c.is_empty()));
|
||||||
|
|
||||||
|
let code_less = code.is_none();
|
||||||
let mut row = Map::new();
|
let mut row = Map::new();
|
||||||
match code {
|
match code {
|
||||||
Some(code) => {
|
Some(code) => {
|
||||||
@@ -276,8 +277,13 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
|
|||||||
row.insert("description".into(), json!(descriptions[i]));
|
row.insert("description".into(), json!(descriptions[i]));
|
||||||
row.insert("qty".into(), decimal_number(&scaled_decimal(qty_scaled, 3)));
|
row.insert("qty".into(), decimal_number(&scaled_decimal(qty_scaled, 3)));
|
||||||
row.insert("rate".into(), money(rate_paise));
|
row.insert("rate".into(), money(rate_paise));
|
||||||
row.insert("uom".into(), json!(cfg.uom_for(&item.unit)));
|
let uom = cfg.uom_for(&item.unit);
|
||||||
|
row.insert("uom".into(), json!(uom));
|
||||||
row.insert("conversion_factor".into(), json!(1));
|
row.insert("conversion_factor".into(), json!(1));
|
||||||
|
if code_less {
|
||||||
|
// Without an item, ERPNext falls back to the stock UOM "Nos" and then refuses a fractional stock qty.
|
||||||
|
row.insert("stock_uom".into(), json!(uom));
|
||||||
|
}
|
||||||
set_if(&mut row, "income_account", &cfg.income_account);
|
set_if(&mut row, "income_account", &cfg.income_account);
|
||||||
set_if(&mut row, "cost_center", &cfg.cost_center);
|
set_if(&mut row, "cost_center", &cfg.cost_center);
|
||||||
if apply_gst {
|
if apply_gst {
|
||||||
@@ -379,7 +385,10 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
|
|||||||
inv.place_of_supply_state_code.trim()
|
inv.place_of_supply_state_code.trim()
|
||||||
};
|
};
|
||||||
body.insert("place_of_supply".into(), json!(place_of_supply_label(pos)?));
|
body.insert("place_of_supply".into(), json!(place_of_supply_label(pos)?));
|
||||||
body.insert("is_reverse_charge".into(), json!(i32::from(inv.reverse_charge)));
|
// Live finding (India Compliance 15.32): a reverse-charge sales invoice must carry negative tax rows on the
|
||||||
|
// separate "... RCM" accounts, so ERPNext's total would exclude the tax Voiced's total includes. The flag
|
||||||
|
// is therefore never sent as 1; the push reports a warning for such invoices instead.
|
||||||
|
body.insert("is_reverse_charge".into(), json!(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
if totals.discount > 0 {
|
if totals.discount > 0 {
|
||||||
@@ -631,6 +640,7 @@ mod tests {
|
|||||||
"qty": 1.0,
|
"qty": 1.0,
|
||||||
"rate": 7310.0,
|
"rate": 7310.0,
|
||||||
"uom": "Nos",
|
"uom": "Nos",
|
||||||
|
"stock_uom": "Nos",
|
||||||
"conversion_factor": 1,
|
"conversion_factor": 1,
|
||||||
"income_account": "Sales - AC",
|
"income_account": "Sales - AC",
|
||||||
}],
|
}],
|
||||||
@@ -680,17 +690,18 @@ mod tests {
|
|||||||
"company_address": "Arun Co-Billing",
|
"company_address": "Arun Co-Billing",
|
||||||
"customer_address": "Client Ltd-Billing",
|
"customer_address": "Client Ltd-Billing",
|
||||||
"place_of_supply": "29-Karnataka",
|
"place_of_supply": "29-Karnataka",
|
||||||
"is_reverse_charge": 1,
|
// Reverse charge is never sent as 1 (see build_sales_invoice); the push warns instead.
|
||||||
|
"is_reverse_charge": 0,
|
||||||
"apply_discount_on": "Net Total",
|
"apply_discount_on": "Net Total",
|
||||||
"discount_amount": 10.0,
|
"discount_amount": 10.0,
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"item_name": "Hours", "description": "Hours", "qty": 1.5, "rate": 1200.0, "uom": "Hour",
|
"item_name": "Hours", "description": "Hours", "qty": 1.5, "rate": 1200.0, "uom": "Hour",
|
||||||
"conversion_factor": 1, "income_account": "Sales - AC", "gst_hsn_code": "998314",
|
"stock_uom": "Hour", "conversion_factor": 1, "income_account": "Sales - AC", "gst_hsn_code": "998314",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"item_name": "Fee", "description": "Fee", "qty": 1.0, "rate": 33.33, "uom": "Nos",
|
"item_name": "Fee", "description": "Fee", "qty": 1.0, "rate": 33.33, "uom": "Nos",
|
||||||
"conversion_factor": 1, "income_account": "Sales - AC",
|
"stock_uom": "Nos", "conversion_factor": 1, "income_account": "Sales - AC",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"taxes": [
|
"taxes": [
|
||||||
@@ -768,6 +779,26 @@ mod tests {
|
|||||||
assert_eq!(rows[3]["item_name"], "Other");
|
assert_eq!(rows[3]["item_name"], "Other");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_code_less_rows_pin_the_stock_uom() {
|
||||||
|
// Live finding: without an item ERPNext defaults the stock UOM to "Nos" and then refuses a fractional
|
||||||
|
// quantity of hours; an item-coded row keeps the item's own stock UOM.
|
||||||
|
let mut conn = registered();
|
||||||
|
let inv = issue(
|
||||||
|
&mut conn,
|
||||||
|
json!({ "items": [
|
||||||
|
{ "description": "Coded", "mode": "rate", "rate": 100.0, "unit": "hour", "quantity": 1.5 },
|
||||||
|
{ "description": "Not coded", "mode": "rate", "rate": 100.0, "unit": "minute", "quantity": 2.5 },
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
let codes = [Some("SVC".to_string()), None];
|
||||||
|
let req = build(&inv, &cfg(), false, &codes, false).unwrap();
|
||||||
|
let rows = req.body["items"].as_array().unwrap();
|
||||||
|
assert!(rows[0].get("stock_uom").is_none());
|
||||||
|
assert_eq!((rows[0]["uom"].as_str(), rows[0]["conversion_factor"].as_i64()), (Some("Hour"), Some(1)));
|
||||||
|
assert_eq!((rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), (Some("Minute"), Some("Minute")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn code_less_rows_need_an_income_account() {
|
fn code_less_rows_need_an_income_account() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ pub mod push;
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod testutil;
|
pub(crate) mod testutil;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod live_tests;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use super::client::{ErpClient, Upload};
|
use super::client::{ErpClient, Upload};
|
||||||
use super::config::{self, ErpnextConfig, NamingMode};
|
use super::config::{self, ErpnextConfig, NamingMode};
|
||||||
|
use super::discovery::ic_number_ok;
|
||||||
use super::errors::{ErpError, ErrorKind};
|
use super::errors::{ErpError, ErrorKind};
|
||||||
use super::mapping::{
|
use super::mapping::{
|
||||||
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, InvoiceContext, Vendor,
|
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, InvoiceContext, Vendor,
|
||||||
@@ -561,8 +562,15 @@ async fn ensure_address(
|
|||||||
}
|
}
|
||||||
validate_address(client).map_err(pre)?;
|
validate_address(client).map_err(pre)?;
|
||||||
let req = build_address(client, customer, l.india_compliance).map_err(pre)?;
|
let req = build_address(client, customer, l.india_compliance).map_err(pre)?;
|
||||||
|
// The link to the address is lost when the local database is (re)built: reuse the customer's matching
|
||||||
|
// address rather than creating a duplicate on every first push.
|
||||||
|
let name = match find_address(http, customer, &req.body).await {
|
||||||
|
Some(existing) => existing,
|
||||||
|
None => {
|
||||||
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
||||||
let name = doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?;
|
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?
|
||||||
|
}
|
||||||
|
};
|
||||||
with_db(db, |c| {
|
with_db(db, |c| {
|
||||||
c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id])
|
c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id])
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
@@ -571,10 +579,52 @@ async fn ensure_address(
|
|||||||
Ok((Some(name), None))
|
Ok((Some(name), None))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An enabled address linked to `customer` with the same first line and PIN code as the one about to be created.
|
||||||
|
/// Any lookup problem counts as "none": the create that follows reports the real error. (A bare `creation` in
|
||||||
|
/// the order-by is ambiguous once a Dynamic Link filter joins the child table: HTTP 500.)
|
||||||
|
async fn find_address(http: &ErpClient, customer: &str, body: &Value) -> Option<String> {
|
||||||
|
let line1 = body.get("address_line1")?.as_str()?;
|
||||||
|
let mut filters = vec![
|
||||||
|
json!(["Dynamic Link", "link_doctype", "=", "Customer"]),
|
||||||
|
json!(["Dynamic Link", "link_name", "=", customer]),
|
||||||
|
json!(["address_line1", "=", line1]),
|
||||||
|
json!(["disabled", "=", 0]),
|
||||||
|
];
|
||||||
|
if let Some(pin) = body.get("pincode").and_then(Value::as_str) {
|
||||||
|
filters.push(json!(["pincode", "=", pin]));
|
||||||
|
}
|
||||||
|
let rows = http.list_resource("Address", &["name"], Value::Array(filters), "`tabAddress`.creation asc").await.ok()?;
|
||||||
|
rows.first()?.get("name")?.as_str().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
struct RemoteDoc {
|
struct RemoteDoc {
|
||||||
name: String,
|
name: String,
|
||||||
docstatus: i64,
|
docstatus: i64,
|
||||||
created: bool,
|
created: bool,
|
||||||
|
/// The grand total ERPNext computed, when the response carried it.
|
||||||
|
total_paise: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ERPNext's default Rounding Method is Banker's Rounding (half to even); Voiced rounds half up. They only
|
||||||
|
/// differ on half-paise ties (e.g. 9% of 10.50), by one paise per tie.
|
||||||
|
fn rounding_hint(theirs: i64, ours: i64) -> &'static str {
|
||||||
|
if (theirs - ours).abs() <= 5 {
|
||||||
|
" The usual cause is ERPNext's rounding of half-paise amounts: set System Settings > Rounding Method to \"Commercial Rounding\" in ERPNext."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_conflict(name: &str, number: &str, theirs: i64, ours: i64) -> ErpError {
|
||||||
|
ErpError::new(
|
||||||
|
ErrorKind::Conflict,
|
||||||
|
format!(
|
||||||
|
"ERPNext has {name} for invoice {number}, but its total is {} and Voiced's is {}. Voiced does not overwrite it and does not submit it: fix or delete the ERPNext document, then push again.{}",
|
||||||
|
paise_to_decimal(theirs),
|
||||||
|
paise_to_decimal(ours),
|
||||||
|
rounding_hint(theirs, ours)
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An existing remote document is only accepted when its total equals Voiced's; anything else is a
|
/// An existing remote document is only accepted when its total equals Voiced's; anything else is a
|
||||||
@@ -589,16 +639,10 @@ fn accept_existing(name: &str, doc: &Value, inv: &Invoice) -> Result<RemoteDoc,
|
|||||||
}
|
}
|
||||||
let ours = gst::rupees_to_paise(inv.total);
|
let ours = gst::rupees_to_paise(inv.total);
|
||||||
match doc_total_paise(doc) {
|
match doc_total_paise(doc) {
|
||||||
Some(theirs) if theirs == ours => Ok(RemoteDoc { name: name.to_string(), docstatus, created: false }),
|
Some(theirs) if theirs == ours => {
|
||||||
Some(theirs) => Err(ErpError::new(
|
Ok(RemoteDoc { name: name.to_string(), docstatus, created: false, total_paise: Some(theirs) })
|
||||||
ErrorKind::Conflict,
|
}
|
||||||
format!(
|
Some(theirs) => Err(total_conflict(name, &inv.number, theirs, ours)),
|
||||||
"ERPNext already has {name} for invoice {}, but its total is {} and Voiced's is {}. Voiced does not overwrite it: fix or delete the ERPNext document, then push again.",
|
|
||||||
inv.number,
|
|
||||||
paise_to_decimal(theirs),
|
|
||||||
paise_to_decimal(ours)
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
None => Err(ErpError::new(
|
None => Err(ErpError::new(
|
||||||
ErrorKind::Conflict,
|
ErrorKind::Conflict,
|
||||||
format!("ERPNext already has {name} for invoice {}, but its total could not be read to compare.", inv.number),
|
format!("ERPNext already has {name} for invoice {}, but its total could not be read to compare.", inv.number),
|
||||||
@@ -647,7 +691,7 @@ async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltReque
|
|||||||
return Err(ErpError::protocol("ERPNext did not return the new Sales Invoice's name."))
|
return Err(ErpError::protocol("ERPNext did not return the new Sales Invoice's name."))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true })
|
Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true, total_paise: doc_total_paise(&doc) })
|
||||||
}
|
}
|
||||||
Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => {
|
Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => {
|
||||||
// The mirrored name is taken: either a repeat of an earlier push or someone else's document.
|
// The mirrored name is taken: either a repeat of an earlier push or someone else's document.
|
||||||
@@ -696,8 +740,30 @@ fn attachment_file_name(number: &str) -> String {
|
|||||||
format!("{}.pdf", if cleaned.is_empty() { "invoice" } else { cleaned })
|
format!("{}.pdf", if cleaned.is_empty() { "invoice" } else { cleaned })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf) -> Result<(), ErpError> {
|
/// True when the document already carries a file of this name. Used when the local row lost its attachment
|
||||||
|
/// hash (a re-adopted document), so the same PDF is not attached twice.
|
||||||
|
async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) -> Result<bool, ErpError> {
|
||||||
|
let rows = http
|
||||||
|
.list_resource(
|
||||||
|
"File",
|
||||||
|
&["name"],
|
||||||
|
json!([
|
||||||
|
["attached_to_doctype", "=", DOCTYPE_INVOICE],
|
||||||
|
["attached_to_name", "=", remote_name],
|
||||||
|
["file_name", "=", file_name]
|
||||||
|
]),
|
||||||
|
"creation asc",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(!rows.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `adopted`: the document was found, not created by this push, and nothing is recorded as attached.
|
||||||
|
async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf, adopted: bool) -> Result<(), ErpError> {
|
||||||
let file_name = attachment_file_name(&l.invoice.number);
|
let file_name = attachment_file_name(&l.invoice.number);
|
||||||
|
if adopted && has_attachment(http, remote_name, &file_name).await.unwrap_or(false) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let fields = [
|
let fields = [
|
||||||
("doctype", DOCTYPE_INVOICE.to_string()),
|
("doctype", DOCTYPE_INVOICE.to_string()),
|
||||||
("docname", remote_name.to_string()),
|
("docname", remote_name.to_string()),
|
||||||
@@ -754,6 +820,9 @@ fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, stat
|
|||||||
fn failure_text(step: &str, e: &ErpError) -> String {
|
fn failure_text(step: &str, e: &ErpError) -> String {
|
||||||
if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) {
|
if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) {
|
||||||
e.to_string()
|
e.to_string()
|
||||||
|
} else if e.kind == ErrorKind::Validation && e.message.contains("cannot be a fraction") {
|
||||||
|
// ERPNext names the row and the UOM already; say what to change in Voiced's settings.
|
||||||
|
format!("Could not {step}: {e} Map this unit to a UOM that allows fractions (ERPNext settings, unit mapping), or use a whole quantity.")
|
||||||
} else {
|
} else {
|
||||||
format!("Could not {step}: {e}")
|
format!("Could not {step}: {e}")
|
||||||
}
|
}
|
||||||
@@ -780,6 +849,12 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
|||||||
st.warnings.extend(address_warning);
|
st.warnings.extend(address_warning);
|
||||||
|
|
||||||
st.step = "";
|
st.step = "";
|
||||||
|
if inv.reverse_charge && l.india_compliance && l.vendor.registered {
|
||||||
|
st.warnings.push(
|
||||||
|
"This invoice is marked reverse charge in Voiced. It was sent as a normal taxed invoice: India Compliance books reverse-charge sales on separate RCM tax accounts with negative tax rows, which would not match Voiced's total. Check its GST treatment in ERPNext."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
let ctx = InvoiceContext {
|
let ctx = InvoiceContext {
|
||||||
invoice: inv,
|
invoice: inv,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@@ -807,11 +882,25 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
st.payload_hash = hash;
|
st.payload_hash = hash;
|
||||||
|
if cfg.naming_mode == NamingMode::Mirror && l.india_compliance && !ic_number_ok(&inv.number) {
|
||||||
|
return Err(pre(format!(
|
||||||
|
"Invoice number {} is longer than 16 characters or has characters India Compliance refuses (letters, digits, - and / only), so ERPNext would reject it. Use the ERPNext series naming mode, or start a new Voiced series with a shorter prefix.",
|
||||||
|
inv.number
|
||||||
|
)));
|
||||||
|
}
|
||||||
st.step = "create the Sales Invoice";
|
st.step = "create the Sales Invoice";
|
||||||
let doc = create_or_find(http, l, &built).await?;
|
let doc = create_or_find(http, l, &built).await?;
|
||||||
st.remote_name = doc.name;
|
st.remote_name = doc.name.clone();
|
||||||
st.remote_docstatus = doc.docstatus;
|
st.remote_docstatus = doc.docstatus;
|
||||||
st.created = doc.created;
|
st.created = doc.created;
|
||||||
|
// The totals must agree before anything else happens (no PDF, never submitted); the row keeps the
|
||||||
|
// remote name, so the draft is found again after it is fixed or deleted.
|
||||||
|
if let Some(theirs) = doc.total_paise {
|
||||||
|
let ours = gst::rupees_to_paise(inv.total);
|
||||||
|
if theirs != ours {
|
||||||
|
return Err(total_conflict(&doc.name, &inv.number, theirs, ours));
|
||||||
|
}
|
||||||
|
}
|
||||||
// Keep the remote name even if the next steps fail.
|
// Keep the remote name even if the next steps fail.
|
||||||
persist(db, inv.id, prev, st, "synced", "")?;
|
persist(db, inv.id, prev, st, "synced", "")?;
|
||||||
}
|
}
|
||||||
@@ -834,7 +923,8 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
|||||||
}
|
}
|
||||||
if let Some(pdf) = pdf {
|
if let Some(pdf) = pdf {
|
||||||
st.step = "attach the PDF";
|
st.step = "attach the PDF";
|
||||||
match attach_pdf(http, l, &st.remote_name.clone(), pdf).await {
|
let adopted = !st.created && st.attachment_sha256.is_empty();
|
||||||
|
match attach_pdf(http, l, &st.remote_name.clone(), pdf, adopted).await {
|
||||||
Ok(()) => st.attachment_sha256 = pdf.sha256.clone(),
|
Ok(()) => st.attachment_sha256 = pdf.sha256.clone(),
|
||||||
// The invoice itself is in ERPNext; a failed upload is a warning, retried by the next push.
|
// The invoice itself is in ERPNext; a failed upload is a warning, retried by the next push.
|
||||||
Err(e) => st.warnings.push(format!("The PDF was not attached: {e}")),
|
Err(e) => st.warnings.push(format!("The PDF was not attached: {e}")),
|
||||||
@@ -924,9 +1014,13 @@ pub struct PaymentEntryInput<'a> {
|
|||||||
|
|
||||||
/// Turns the unsaved dict from `get_payment_entry` into the Payment Entry to insert and submit.
|
/// Turns the unsaved dict from `get_payment_entry` into the Payment Entry to insert and submit.
|
||||||
///
|
///
|
||||||
/// UNVERIFIED against a live ERPNext (check in F4): the deduction row fields (`account`, `cost_center`,
|
/// Verified against a live ERPNext v15.121.6 (F4; see scripts/erpnext-e2e): `bank_account` in the
|
||||||
/// `amount`), the sign ERPNext expects for a TDS deduction on a receipt, and whether `allocated_amount` must be
|
/// `get_payment_entry` query is the *Account* name and ends up as `paid_to` (the Payment Entry's own
|
||||||
/// cash plus TDS for the difference amount to come out zero. Everything that depends on those guesses is here.
|
/// `bank_account` link, a Bank Account document, stays empty). A Payment Entry that settles an invoice with TDS has
|
||||||
|
/// `paid_amount = received_amount = cash`, the invoice reference's `allocated_amount = cash + TDS`, and one
|
||||||
|
/// `deductions` row `{account: TDS receivable, cost_center, amount: +TDS}` (a positive amount); then
|
||||||
|
/// `difference_amount` is 0, `total_allocated_amount = cash + TDS` and the invoice's outstanding drops by
|
||||||
|
/// cash + TDS. A partial payment without TDS leaves the rest outstanding.
|
||||||
pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result<Value, String> {
|
pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result<Value, String> {
|
||||||
let mut doc: Map<String, Value> = draft.as_object().cloned().ok_or("ERPNext returned an unexpected payment draft.")?;
|
let mut doc: Map<String, Value> = draft.as_object().cloned().ok_or("ERPNext returned an unexpected payment draft.")?;
|
||||||
doc.retain(|k, _| !k.starts_with("__"));
|
doc.retain(|k, _| !k.starts_with("__"));
|
||||||
@@ -1043,6 +1137,12 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if row.amount_paise <= 0 {
|
||||||
|
// Live finding: ERPNext answers a zero paid amount with a bare "Paid Amount is mandatory".
|
||||||
|
return fail(pre(
|
||||||
|
"This payment records TDS only, with no cash received. ERPNext's Payment Entry needs a paid amount above zero, so it was not sent: book the TDS in ERPNext (for example as a Journal Entry), or send the payment once the cash is recorded.",
|
||||||
|
));
|
||||||
|
}
|
||||||
if cfg.payment_bank_account.trim().is_empty() {
|
if cfg.payment_bank_account.trim().is_empty() {
|
||||||
return fail(pre("Set the payment bank account in the ERPNext settings first."));
|
return fail(pre("Set the payment bank account in the ERPNext settings first."));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,139 @@ async fn submit_on_push_setting_is_the_default_and_a_failed_submit_keeps_the_dra
|
|||||||
assert!(row.last_error.contains("HSN code is mandatory"));
|
assert!(row.last_error.contains("HSN code is mandatory"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_created_invoice_whose_total_differs_is_a_conflict_that_is_kept_and_never_submitted() {
|
||||||
|
// Live finding: ERPNext's default Banker's Rounding disagrees with Voiced's half-up by a paise on ties.
|
||||||
|
let fx = Fx::new(|_| {});
|
||||||
|
let inv = fx.issue("Client Ltd");
|
||||||
|
fx.archive(&inv);
|
||||||
|
let submit = format!("POST /api/v2/document/Sales%20Invoice/{}/method/submit", enc(&inv.number));
|
||||||
|
let mut routes = master_routes();
|
||||||
|
routes.push(route(submit.clone(), 200, json!({ "data": { "docstatus": 1 } })));
|
||||||
|
routes.push(mirror_create(&inv.number, 0, inv.total - 0.02));
|
||||||
|
let m = mock(routes).await;
|
||||||
|
|
||||||
|
let r = fx.push(&m, inv.id, Some(true)).await;
|
||||||
|
assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}");
|
||||||
|
assert!(r.created && !r.attached, "{r:?}");
|
||||||
|
let msg = r.error.as_deref().unwrap();
|
||||||
|
assert!(msg.contains("does not submit") && msg.contains("Commercial Rounding"), "{msg}");
|
||||||
|
assert_eq!(hits(&m, &submit), 0);
|
||||||
|
assert_eq!(hits(&m, UPLOAD), 0);
|
||||||
|
// The row keeps the remote name, so the draft is recognised later.
|
||||||
|
let row = fx.sync_row(inv.id).unwrap();
|
||||||
|
assert_eq!((row.status.as_str(), row.remote_name.as_str(), row.remote_docstatus), ("conflict", inv.number.as_str(), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_large_total_difference_gets_no_rounding_hint() {
|
||||||
|
let fx = Fx::new(|_| {});
|
||||||
|
let inv = fx.issue("Client Ltd");
|
||||||
|
let mut routes = master_routes();
|
||||||
|
routes.push(mirror_create(&inv.number, 0, inv.total + 100.0));
|
||||||
|
let m = mock(routes).await;
|
||||||
|
let r = fx.push(&m, inv.id, None).await;
|
||||||
|
assert!(!r.ok && r.status == "conflict", "{r:?}");
|
||||||
|
assert!(!r.error.as_deref().unwrap().contains("Rounding"), "{r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_existing_customer_address_is_reused_instead_of_created_again() {
|
||||||
|
let fx = Fx::new(|_| {});
|
||||||
|
let inv = fx.issue("Client Ltd");
|
||||||
|
let mut routes = master_routes();
|
||||||
|
routes.insert(0, route("GET /api/resource/Address?", 200, json!({ "data": [{ "name": "Client Ltd-Billing-3" }] })));
|
||||||
|
routes.push(mirror_create(&inv.number, 0, inv.total));
|
||||||
|
let m = mock(routes).await;
|
||||||
|
|
||||||
|
let r = fx.push(&m, inv.id, None).await;
|
||||||
|
assert!(r.ok, "{r:?}");
|
||||||
|
assert_eq!(hits(&m, ADDRESS_POST), 0);
|
||||||
|
assert_eq!(json_body(&first_request(&m, SI_POST_V2))["customer_address"], "Client Ltd-Billing-3");
|
||||||
|
assert_eq!(fx.scalar("SELECT erpnext_address FROM clients WHERE id = 1").as_deref(), Some("Client Ltd-Billing-3"));
|
||||||
|
let lookup = first_request(&m, "GET /api/resource/Address?");
|
||||||
|
assert!(lookup.contains("address_line1") && lookup.contains("Customer"), "{lookup}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_adopted_invoice_that_already_carries_the_pdf_is_not_attached_twice() {
|
||||||
|
let fx = Fx::new(|_| {});
|
||||||
|
let inv = fx.issue("Client Ltd");
|
||||||
|
let sha = fx.archive(&inv);
|
||||||
|
let mut routes = master_routes();
|
||||||
|
routes.insert(0, route("GET /api/resource/File?", 200, json!({ "data": [{ "name": "f1" }] })));
|
||||||
|
routes.push(route(SI_POST_V2, 409, json!({ "exc_type": "DuplicateEntryError", "_error_message": "already exists" })));
|
||||||
|
routes.push(route(
|
||||||
|
format!("GET /api/v2/document/Sales%20Invoice/{}", enc(&inv.number)),
|
||||||
|
200,
|
||||||
|
json!({ "data": { "name": inv.number, "docstatus": 0, "grand_total": inv.total } }),
|
||||||
|
));
|
||||||
|
let m = mock(routes).await;
|
||||||
|
|
||||||
|
let r = fx.push(&m, inv.id, None).await;
|
||||||
|
assert!(r.ok && !r.created && r.attached, "{r:?}");
|
||||||
|
assert_eq!(hits(&m, UPLOAD), 0);
|
||||||
|
assert_eq!(fx.sync_row(inv.id).unwrap().attachment_sha256, sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_fractional_quantity_refused_by_erpnext_says_what_to_change() {
|
||||||
|
let fx = Fx::new(|_| {});
|
||||||
|
let inv = fx.issue("Client Ltd");
|
||||||
|
let mut routes = master_routes();
|
||||||
|
routes.push(route(
|
||||||
|
SI_POST_V2,
|
||||||
|
417,
|
||||||
|
json!({ "_error_message": "Row 1: Quantity (1.5) cannot be a fraction. To allow this, disable 'Must be Whole Number' in UOM Nos." }),
|
||||||
|
));
|
||||||
|
let m = mock(routes).await;
|
||||||
|
let r = fx.push(&m, inv.id, None).await;
|
||||||
|
let msg = r.error.as_deref().unwrap();
|
||||||
|
assert!(!r.ok && msg.contains("Row 1: Quantity (1.5) cannot be a fraction") && msg.contains("unit mapping"), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_reverse_charge_invoice_is_sent_as_a_normal_one_with_a_warning() {
|
||||||
|
let fx = Fx::new(|_| {});
|
||||||
|
let inv = fx.issue("Client Ltd");
|
||||||
|
fx.db.lock().unwrap().execute("UPDATE invoices SET reverse_charge = 1 WHERE id = ?1", params![inv.id]).unwrap();
|
||||||
|
let mut routes = master_routes();
|
||||||
|
routes.push(mirror_create(&inv.number, 0, inv.total));
|
||||||
|
let m = mock(routes).await;
|
||||||
|
let r = fx.push(&m, inv.id, None).await;
|
||||||
|
assert!(r.ok, "{r:?}");
|
||||||
|
assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings);
|
||||||
|
assert_eq!(json_body(&first_request(&m, SI_POST_V2))["is_reverse_charge"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_mirrored_number_india_compliance_would_refuse_is_stopped_before_anything_is_created() {
|
||||||
|
let fx = Fx::new(|_| {});
|
||||||
|
let inv = fx.issue("Client Ltd");
|
||||||
|
fx.db.lock().unwrap().execute("UPDATE invoices SET number = 'IMPORTED/2026-INVOICE-0001' WHERE id = ?1", params![inv.id]).unwrap();
|
||||||
|
let mut routes = master_routes();
|
||||||
|
routes.push(mirror_create("IMPORTED/2026-INVOICE-0001", 0, inv.total));
|
||||||
|
let m = mock(routes).await;
|
||||||
|
let r = fx.push(&m, inv.id, None).await;
|
||||||
|
assert!(!r.ok && r.error_kind == Some(ErrorKind::Precondition), "{r:?}");
|
||||||
|
assert!(r.error.as_deref().unwrap().contains("16 characters"), "{r:?}");
|
||||||
|
assert_eq!(hits(&m, SI_POST_V2), 0);
|
||||||
|
|
||||||
|
// Series naming lets ERPNext pick the name, so the same number is fine there.
|
||||||
|
let fx = Fx::new(|c| {
|
||||||
|
c.naming_mode = NamingMode::Series;
|
||||||
|
c.naming_series = "SINV-.YY.-".into();
|
||||||
|
});
|
||||||
|
let inv = fx.issue("Client Ltd");
|
||||||
|
fx.db.lock().unwrap().execute("UPDATE invoices SET number = 'IMPORTED/2026-INVOICE-0001' WHERE id = ?1", params![inv.id]).unwrap();
|
||||||
|
let mut routes = master_routes();
|
||||||
|
routes.push(route(SI_LIST_V1, 200, json!({ "data": [] })));
|
||||||
|
routes.push(route(SI_POST_V1, 200, json!({ "data": { "name": "SINV-26-00001", "docstatus": 0, "grand_total": inv.total } })));
|
||||||
|
let m = mock(routes).await;
|
||||||
|
let r = fx.push(&m, inv.id, None).await;
|
||||||
|
assert!(r.ok, "{r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mirrored_409_with_the_same_total_is_recorded_as_synced() {
|
async fn mirrored_409_with_the_same_total_is_recorded_as_synced() {
|
||||||
let fx = Fx::new(|_| {});
|
let fx = Fx::new(|_| {});
|
||||||
@@ -827,6 +960,20 @@ async fn tds_account_is_required_only_for_payments_with_tds_and_the_bank_always(
|
|||||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("payment bank account"), "{r:?}");
|
assert!(!r.ok && r.error.as_deref().unwrap().contains("payment bank account"), "{r:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_tds_only_payment_is_refused_locally_with_a_readable_reason() {
|
||||||
|
// Live finding: ERPNext's own answer to a zero paid amount is just "Paid Amount is mandatory".
|
||||||
|
let fx = Fx::new(|_| {});
|
||||||
|
let inv = fx.issue("Client Ltd");
|
||||||
|
mark_invoice_synced(&fx, &inv, 1);
|
||||||
|
let pid = add_payment(&fx, &inv, 0, 100_000);
|
||||||
|
let m = mock(vec![]).await;
|
||||||
|
let r = push_payment(&fx.db, &client(&m), pid).await;
|
||||||
|
assert!(!r.ok && r.error_kind == Some(ErrorKind::Precondition), "{r:?}");
|
||||||
|
assert!(r.error.as_deref().unwrap().contains("TDS only"), "{r:?}");
|
||||||
|
assert!(m.requests.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn payment_errors_come_back_readable_and_leave_the_payment_unlinked() {
|
async fn payment_errors_come_back_readable_and_leave_the_payment_unlinked() {
|
||||||
let fx = Fx::new(|_| {});
|
let fx = Fx::new(|_| {});
|
||||||
@@ -846,7 +993,7 @@ async fn payment_errors_come_back_readable_and_leave_the_payment_unlinked() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn payment_entry_mapping_is_pure_and_covers_the_unverified_deduction_fields() {
|
fn payment_entry_mapping_is_pure_and_matches_the_shape_verified_on_a_live_site() {
|
||||||
let input = |tds: i64, reference: &'static str| PaymentEntryInput {
|
let input = |tds: i64, reference: &'static str| PaymentEntryInput {
|
||||||
payment_id: 7,
|
payment_id: 7,
|
||||||
invoice_number: "AP/2026-001",
|
invoice_number: "AP/2026-001",
|
||||||
|
|||||||
Reference in New Issue
Block a user