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:
2026-10-04 22:09:03 +05:30
parent ff29748f4e
commit 18762e42cb
11 changed files with 1554 additions and 39 deletions
+5
View File
@@ -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
+148
View File
@@ -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.
+66
View File
@@ -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:
+42
View File
@@ -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)."
+197
View File
@@ -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()
+35 -14
View File
@@ -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", ["Dynamic Link", "link_doctype", "=", "Company"],
&["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"], ["Dynamic Link", "link_name", "=", company],
json!([ ["disabled", "=", 0]
["Dynamic Link", "link_doctype", "=", "Company"], ]);
["Dynamic Link", "link_name", "=", company], let base = ["name", "address_title", "address_line1", "city", "state", "pincode"];
["disabled", "=", 0] let with_gstin = ["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"];
]), // `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted").
"name asc", 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?,
.await other => other?,
.map(|rows| { };
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:?}");
}
+36 -5
View File
@@ -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;
+119 -19
View File
@@ -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)?;
let resp = http.post(req.path, &req.body, req.idempotent).await?; // The link to the address is lost when the local database is (re)built: reuse the customer's matching
let name = doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?; // 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?;
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",