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 3591bb9f7d
commit 0f27e5ddc1
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": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001" }] }))
} else {
reply(200, json!({ "data": [] }))
}
})
.await;
let options = client(&mock).load_options("Test Co").await.unwrap();
assert_eq!(options.company_addresses.len(), 1);
assert_eq!(options.company_addresses[0].name, "Test 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;
@@ -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())
.or_else(|| Some(cfg.default_item_code.trim()).filter(|c| !c.is_empty()));
let code_less = code.is_none();
let mut row = Map::new();
match 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("qty".into(), decimal_number(&scaled_decimal(qty_scaled, 3)));
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));
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, "cost_center", &cfg.cost_center);
if apply_gst {
@@ -379,7 +385,10 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
inv.place_of_supply_state_code.trim()
};
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 {
@@ -631,6 +640,7 @@ mod tests {
"qty": 1.0,
"rate": 7310.0,
"uom": "Nos",
"stock_uom": "Nos",
"conversion_factor": 1,
"income_account": "Sales - AC",
}],
@@ -680,17 +690,18 @@ mod tests {
"company_address": "Test Co-Billing",
"customer_address": "Client Ltd-Billing",
"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",
"discount_amount": 10.0,
"items": [
{
"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",
"conversion_factor": 1, "income_account": "Sales - AC",
"stock_uom": "Nos", "conversion_factor": 1, "income_account": "Sales - AC",
},
],
"taxes": [
@@ -768,6 +779,26 @@ mod tests {
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]
fn code_less_rows_need_an_income_account() {
let mut conn = registered();
@@ -13,3 +13,6 @@ pub mod push;
#[cfg(test)]
pub(crate) mod testutil;
#[cfg(test)]
mod live_tests;
+119 -19
View File
@@ -6,6 +6,7 @@
use super::client::{ErpClient, Upload};
use super::config::{self, ErpnextConfig, NamingMode};
use super::discovery::ic_number_ok;
use super::errors::{ErpError, ErrorKind};
use super::mapping::{
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)?;
let req = build_address(client, customer, l.india_compliance).map_err(pre)?;
let resp = http.post(req.path, &req.body, req.idempotent).await?;
let name = doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?;
// The link to the address is lost when the local database is (re)built: reuse the customer's matching
// address rather than creating a duplicate on every first push.
let name = match find_address(http, customer, &req.body).await {
Some(existing) => existing,
None => {
let resp = http.post(req.path, &req.body, req.idempotent).await?;
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?
}
};
with_db(db, |c| {
c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id])
.map(|_| ())
@@ -571,10 +579,52 @@ async fn ensure_address(
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 {
name: String,
docstatus: i64,
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
@@ -589,16 +639,10 @@ fn accept_existing(name: &str, doc: &Value, inv: &Invoice) -> Result<RemoteDoc,
}
let ours = gst::rupees_to_paise(inv.total);
match doc_total_paise(doc) {
Some(theirs) if theirs == ours => Ok(RemoteDoc { name: name.to_string(), docstatus, created: false }),
Some(theirs) => Err(ErpError::new(
ErrorKind::Conflict,
format!(
"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)
),
)),
Some(theirs) if theirs == ours => {
Ok(RemoteDoc { name: name.to_string(), docstatus, created: false, total_paise: Some(theirs) })
}
Some(theirs) => Err(total_conflict(name, &inv.number, theirs, ours)),
None => Err(ErpError::new(
ErrorKind::Conflict,
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."))
}
};
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 => {
// 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 })
}
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);
if adopted && has_attachment(http, remote_name, &file_name).await.unwrap_or(false) {
return Ok(());
}
let fields = [
("doctype", DOCTYPE_INVOICE.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 {
if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) {
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 {
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.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 {
invoice: inv,
config: cfg,
@@ -807,11 +882,25 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
}
None => {
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";
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.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.
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 {
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(),
// 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}")),
@@ -924,9 +1014,13 @@ pub struct PaymentEntryInput<'a> {
/// 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`,
/// `amount`), the sign ERPNext expects for a TDS deduction on a receipt, and whether `allocated_amount` must be
/// cash plus TDS for the difference amount to come out zero. Everything that depends on those guesses is here.
/// Verified against a live ERPNext v15.121.6 (F4; see scripts/erpnext-e2e): `bank_account` in the
/// `get_payment_entry` query is the *Account* name and ends up as `paid_to` (the Payment Entry's own
/// `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> {
let mut doc: Map<String, Value> = draft.as_object().cloned().ok_or("ERPNext returned an unexpected payment draft.")?;
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() {
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"));
}
#[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]
async fn mirrored_409_with_the_same_total_is_recorded_as_synced() {
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:?}");
}
#[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]
async fn payment_errors_come_back_readable_and_leave_the_payment_unlinked() {
let fx = Fx::new(|_| {});
@@ -846,7 +993,7 @@ async fn payment_errors_come_back_readable_and_leave_the_payment_unlinked() {
}
#[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 {
payment_id: 7,
invoice_number: "INV/2026-001",