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
+35 -14
View File
@@ -286,18 +286,19 @@ impl ErpClient {
return Ok(out);
}
load!("companyAddresses", out.company_addresses, async {
self.list_resource(
"Address",
&["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"],
json!([
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", company],
["disabled", "=", 0]
]),
"name asc",
)
.await
.map(|rows| {
let filters = json!([
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", company],
["disabled", "=", 0]
]);
let base = ["name", "address_title", "address_line1", "city", "state", "pincode"];
let with_gstin = ["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"];
// `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted").
let rows = match self.list_resource("Address", &with_gstin, filters.clone(), "name asc").await {
Err(e) if e.kind == ErrorKind::Validation => self.list_resource("Address", &base, filters, "name asc").await?,
other => other?,
};
Ok::<_, ErpError>(
rows.iter()
.filter_map(|r| {
item(
@@ -306,8 +307,8 @@ impl ErpClient {
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 {
self.list_resource(
@@ -792,6 +793,26 @@ mod tests {
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]
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;