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 2ffbf0f66c
commit 8dc4d85e14
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()