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
+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()