//! What the connected site is (versions, India Compliance), the lists the settings dropdowns need, and the //! connection test that turns all of it into warnings. use super::client::ErpClient; use super::config::{ErpnextConfig, NamingMode}; use super::errors::{ErpError, ErrorKind}; use serde::Serialize; use serde_json::{json, Value}; // ---- versions and feature switches ---- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] pub struct Version { pub major: u32, pub minor: u32, pub patch: u32, } impl Version { /// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored. pub fn parse(s: &str) -> Option { let s = s.trim().trim_start_matches(['v', 'V']); let numeric: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect(); let mut parts = numeric.split('.').filter(|p| !p.is_empty()); let major = parts.next()?.parse().ok()?; let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); Some(Version { major, minor, patch }) } } #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct ServerVersions { pub frappe: Option, pub erpnext: Option, pub india_compliance: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct FeatureSwitches { /// API v2 keeps a posted document name verbatim (Frappe >= 15.73, or 16+). pub v2_naming: bool, /// India Compliance is installed, so the GST header and item fields apply. pub india_compliance: bool, } pub fn feature_switches(v: &ServerVersions) -> FeatureSwitches { let frappe = v.frappe.as_deref().and_then(Version::parse); FeatureSwitches { v2_naming: frappe.is_some_and(|f| (f.major, f.minor) >= (15, 73)), india_compliance: v.india_compliance.is_some(), } } /// Reads `{"message": {"frappe": {"version": "15.73.0"}, ...}}`. pub fn parse_versions(value: &Value) -> ServerVersions { let apps = value.get("message").unwrap_or(value); let version_of = |app: &str| -> Option { let entry = apps.get(app)?; let v = entry.get("version").and_then(Value::as_str).or_else(|| entry.as_str())?; let v = v.trim(); (!v.is_empty()).then(|| v.to_string()) }; ServerVersions { frappe: version_of("frappe"), erpnext: version_of("erpnext"), india_compliance: version_of("india_compliance"), } } // ---- number rules (India Compliance) ---- pub const IC_MAX_NUMBER_LEN: usize = 16; /// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`. pub fn ic_number_ok(number: &str) -> bool { let mut chars = number.chars(); let Some(first) = chars.next() else { return false }; first.is_alphanumeric() && number.chars().count() <= IC_MAX_NUMBER_LEN && chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/') } /// Length of a number generated from a Frappe naming series such as `SINV-.YY.-.#####`. A series with no /// `#` run gets Frappe's default five digits. pub fn naming_series_expanded_len(series: &str) -> usize { let mut len = 0; let mut has_digits = false; for part in series.split('.') { if part.is_empty() { continue; } if part.chars().all(|c| c == '#') { len += part.len(); has_digits = true; continue; } len += match part { "YY" | "MM" | "DD" => 2, "YYYY" => 4, "FY" => 7, literal => literal.chars().count(), }; } if !has_digits { len += 5; } len } // ---- options for the settings dropdowns ---- #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct OptionItem { /// The value to store (the document name). pub name: String, pub label: String, pub detail: String, } #[derive(Debug, Clone, Serialize)] pub struct OptionError { pub list: String, pub message: String, } #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct ErpnextOptions { pub companies: Vec, pub company_addresses: Vec, pub income_accounts: Vec, pub tax_accounts: Vec, pub tax_templates: Vec, pub naming_series: Vec, pub item_groups: Vec, pub uoms: Vec, pub cost_centers: Vec, pub price_lists: Vec, pub customer_groups: Vec, pub territories: Vec, /// Lists that could not be loaded (one failing list does not hide the rest). pub errors: Vec, } fn text<'a>(row: &'a Value, key: &str) -> &'a str { row.get(key).and_then(Value::as_str).unwrap_or("").trim() } fn item(row: &Value, label_key: &str, detail: String) -> Option { let name = text(row, "name"); if name.is_empty() { return None; } let label = text(row, label_key); Some(OptionItem { name: name.to_string(), label: if label.is_empty() { name.to_string() } else { label.to_string() }, detail, }) } fn join(parts: &[&str]) -> String { parts.iter().filter(|p| !p.is_empty()).cloned().collect::>().join(" · ") } /// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank. pub fn parse_naming_series(doctype_response: &Value) -> Vec { let docs = doctype_response .get("docs") .or_else(|| doctype_response.get("message").and_then(|m| m.get("docs"))) .and_then(Value::as_array); let Some(doc) = docs.and_then(|d| d.first()) else { return Vec::new() }; let Some(fields) = doc.get("fields").and_then(Value::as_array) else { return Vec::new() }; let Some(field) = fields.iter().find(|f| text(f, "fieldname") == "naming_series") else { return Vec::new(); }; text(field, "options") .lines() .map(str::trim) .filter(|l| !l.is_empty()) .map(|l| OptionItem { name: l.to_string(), label: l.to_string(), detail: format!("e.g. {} digits", naming_series_expanded_len(l)) }) .collect() } impl ErpClient { pub async fn get_logged_user(&self) -> Result { let v = self.get(&["api", "method", "frappe.auth.get_logged_user"], &[]).await?; let user = v.get("message").and_then(Value::as_str).unwrap_or("").trim().to_string(); if user.is_empty() || user.eq_ignore_ascii_case("guest") { return Err(ErpError { kind: ErrorKind::Auth, status: None, message: "The API key and secret were not accepted (the site treated the request as Guest).".into(), exc_type: None, }); } Ok(user) } pub async fn get_versions(&self) -> Result { let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?; Ok(parse_versions(&v)) } /// `GET /api/resource/` with every row (`limit_page_length=0`). pub async fn list_resource( &self, doctype: &str, fields: &[&str], filters: Value, order_by: &str, ) -> Result, ErpError> { let mut query = vec![ ("fields", json!(fields).to_string()), ("limit_page_length", "0".to_string()), ("order_by", order_by.to_string()), ]; if !filters.is_null() { query.push(("filters", filters.to_string())); } let v = self.get(&["api", "resource", doctype], &query).await?; Ok(v.get("data").and_then(Value::as_array).cloned().unwrap_or_default()) } pub async fn load_options(&self, company: &str) -> Result { let mut out = ErpnextOptions::default(); let company = company.trim(); macro_rules! load { ($list:literal, $target:expr, $call:expr) => { match $call.await { Ok(rows) => $target = rows, Err(e) if matches!(e.kind, ErrorKind::Auth | ErrorKind::Network | ErrorKind::Timeout | ErrorKind::Config | ErrorKind::Protocol) => { return Err(e) } Err(e) => out.errors.push(OptionError { list: $list.to_string(), message: e.to_string() }), } }; } load!("companies", out.companies, async { self.list_resource("Company", &["name", "company_name", "abbr", "country", "default_currency"], Value::Null, "name asc") .await .map(|rows| { rows.iter() .filter_map(|r| item(r, "company_name", join(&[text(r, "abbr"), text(r, "default_currency")]))) .collect() }) }); load!("itemGroups", out.item_groups, async { self.list_resource("Item Group", &["name"], Value::Null, "name asc") .await .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) }); load!("uoms", out.uoms, async { self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc") .await .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) }); load!("priceLists", out.price_lists, async { self.list_resource("Price List", &["name"], json!([["enabled", "=", 1], ["selling", "=", 1]]), "name asc") .await .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) }); load!("customerGroups", out.customer_groups, async { self.list_resource("Customer Group", &["name"], json!([["is_group", "=", 0]]), "name asc") .await .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) }); load!("territories", out.territories, async { self.list_resource("Territory", &["name"], json!([["is_group", "=", 0]]), "name asc") .await .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) }); load!("namingSeries", out.naming_series, async { self.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Sales Invoice".to_string())]) .await .map(|v| parse_naming_series(&v)) }); if company.is_empty() { return Ok(out); } load!("companyAddresses", out.company_addresses, async { 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( r, "address_title", join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]), ) }) .collect(), ) }); load!("incomeAccounts", out.income_accounts, async { self.list_resource( "Account", &["name", "account_name", "account_type"], json!([["company", "=", company], ["root_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]), "name asc", ) .await .map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect()) }); load!("taxAccounts", out.tax_accounts, async { self.list_resource( "Account", &["name", "account_name", "account_type"], json!([["company", "=", company], ["account_type", "=", "Tax"], ["is_group", "=", 0], ["disabled", "=", 0]]), "name asc", ) .await .map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect()) }); load!("taxTemplates", out.tax_templates, async { self.list_resource( "Sales Taxes and Charges Template", &["name", "title", "is_default"], json!([["company", "=", company], ["disabled", "=", 0]]), "name asc", ) .await .map(|rows| rows.iter().filter_map(|r| item(r, "title", String::new())).collect()) }); load!("costCenters", out.cost_centers, async { self.list_resource( "Cost Center", &["name", "cost_center_name"], json!([["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]), "name asc", ) .await .map(|rows| rows.iter().filter_map(|r| item(r, "cost_center_name", String::new())).collect()) }); Ok(out) } } // ---- connection test and warnings ---- #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct Warning { pub code: String, pub message: String, } fn warn(code: &str, message: impl Into) -> Warning { Warning { code: code.to_string(), message: message.into() } } /// Facts from the local database that the warnings need. #[derive(Debug, Clone, Default)] pub struct LocalFacts { pub vendor_registered: bool, /// Numbers of issued/cancelled invoices that India Compliance would refuse (at most a few examples). pub invalid_numbers: Vec, /// The number the next invoice would get, if a series is active. pub next_number: Option, } /// What was learned from the site. `None` means the check could not run. #[derive(Debug, Clone, Default)] pub struct RemoteFacts { pub versions: Option, pub company_address_count: Option, /// Rows of GST Settings -> gst_accounts, or the reason they could not be read. pub gst_accounts: Option, String>>, } /// Checks that the configured tax accounts appear as Output accounts for the company in GST Settings. /// Otherwise India Compliance silently files the items as Nil-Rated. pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec { let for_company: Vec<&Value> = rows .iter() .filter(|r| text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output")) .collect(); if for_company.is_empty() { return vec![warn( "gst_accounts_not_configured", format!( "GST accounts are not configured for \"{}\" in GST Settings. India Compliance will file these invoices as Nil-Rated.", cfg.company ), )]; } let has = |field: &str, account: &str| for_company.iter().any(|r| text(r, field) == account); let mut out = Vec::new(); for (field, label, account) in [ ("cgst_account", "CGST", cfg.cgst_account.as_str()), ("sgst_account", "SGST", cfg.sgst_account.as_str()), ("igst_account", "IGST", cfg.igst_account.as_str()), ] { if !account.is_empty() && !has(field, account) { out.push(warn( "gst_account_mismatch", format!("The {label} account \"{account}\" is not the {label} output account in GST Settings; GST returns would not pick the tax up."), )); } } out } /// Pure: turns the configuration and the facts gathered into the list shown after a connection test. pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFacts) -> Vec { let mut w = Vec::new(); let features = remote.versions.as_ref().map(feature_switches); let ic = features.is_some_and(|f| f.india_compliance); if remote.versions.is_none() { w.push(warn( "versions_unknown", "The site's versions could not be read, so India Compliance and API v2 naming were not detected.", )); } if cfg.company.is_empty() { w.push(warn("company_missing", "No company is selected.")); } if cfg.income_account.is_empty() { w.push(warn( "income_account_missing", "No income account is set. Rows without an item code need one, and ERPNext has no fallback for them.", )); } if cfg.naming_mode == NamingMode::Mirror { if let Some(f) = features { if !f.v2_naming { w.push(warn( "mirror_unsupported", "Mirroring the Voiced number needs Frappe 15.73 or newer. Use the ERPNext series naming mode instead.", )); } } } else if cfg.naming_series.is_empty() { w.push(warn("naming_series_missing", "ERPNext series naming is selected but no naming series is chosen.")); } if local.vendor_registered { if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty() { w.push(warn( "tax_accounts_missing", "The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.", )); } if remote.versions.is_some() && !ic { w.push(warn( "ic_not_detected", "India Compliance is not installed on the site, so the GST fields (place of supply, HSN, GSTIN) will not be sent.", )); } } if ic { if local.vendor_registered { match &remote.gst_accounts { Some(Ok(rows)) => w.extend(check_gst_accounts(rows, cfg)), Some(Err(why)) => w.push(warn( "gst_settings_unreadable", format!("GST Settings could not be read ({why}), so the GST accounts were not checked."), )), None => {} } } if cfg.company_address.is_empty() { w.push(warn("company_address_missing", "India Compliance needs a company address on every invoice; none is selected.")); } else if remote.company_address_count == Some(0) { w.push(warn("company_address_missing", "No address is linked to the company on the site.")); } match cfg.naming_mode { NamingMode::Mirror => { if !local.invalid_numbers.is_empty() { w.push(warn( "number_invalid", format!( "India Compliance allows at most {IC_MAX_NUMBER_LEN} characters (letters, digits, - and /). These numbers do not qualify: {}.", local.invalid_numbers.join(", ") ), )); } if let Some(next) = &local.next_number { if !ic_number_ok(next) { w.push(warn( "number_invalid", format!("The next invoice number {next} is longer than {IC_MAX_NUMBER_LEN} characters or has characters India Compliance refuses."), )); } } } NamingMode::Series => { let len = naming_series_expanded_len(&cfg.naming_series); if !cfg.naming_series.is_empty() && len > IC_MAX_NUMBER_LEN { w.push(warn( "naming_series_too_long", format!( "The naming series \"{}\" produces numbers of about {len} characters; India Compliance allows {IC_MAX_NUMBER_LEN}.", cfg.naming_series ), )); } } } } w } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConnectionTest { pub user: String, pub versions: ServerVersions, pub features: FeatureSwitches, /// Same as `features.india_compliance`, for the UI. pub india_compliance: bool, pub warnings: Vec, } /// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only /// produce warnings. pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &LocalFacts) -> Result { let user = client.get_logged_user().await?; let versions = match client.get_versions().await { Ok(v) => Some(v), Err(e) if e.kind == ErrorKind::Auth => return Err(e), Err(_) => None, }; let mut remote = RemoteFacts { versions, ..Default::default() }; let features = remote.versions.as_ref().map(feature_switches); if features.is_some_and(|f| f.india_compliance) { if !cfg.company.is_empty() { remote.company_address_count = client .list_resource( "Address", &["name"], json!([["Dynamic Link", "link_doctype", "=", "Company"], ["Dynamic Link", "link_name", "=", cfg.company], ["disabled", "=", 0]]), "name asc", ) .await .ok() .map(|rows| rows.len()); } if local.vendor_registered { remote.gst_accounts = Some( client .get(&["api", "resource", "GST Settings", "GST Settings"], &[]) .await .map(|v| v.get("data").and_then(|d| d.get("gst_accounts")).and_then(Value::as_array).cloned().unwrap_or_default()) .map_err(|e| e.to_string()), ); } } let warnings = build_warnings(cfg, local, &remote); let versions = remote.versions.unwrap_or_default(); let features = feature_switches(&versions); Ok(ConnectionTest { user, india_compliance: features.india_compliance, features, versions, warnings }) } #[cfg(test)] mod tests { use super::*; fn cfg() -> ErpnextConfig { ErpnextConfig { company: "Arun Co".into(), company_address: "Arun Co-Billing".into(), income_account: "Sales - AC".into(), cgst_account: "Output CGST - AC".into(), sgst_account: "Output SGST - AC".into(), igst_account: "Output IGST - AC".into(), ..Default::default() } } fn ic_versions(frappe: &str) -> RemoteFacts { RemoteFacts { versions: Some(ServerVersions { frappe: Some(frappe.into()), erpnext: Some("15.121.6".into()), india_compliance: Some("15.0.0".into()), }), company_address_count: Some(1), gst_accounts: Some(Ok(vec![json!({ "company": "Arun Co", "account_type": "Output", "cgst_account": "Output CGST - AC", "sgst_account": "Output SGST - AC", "igst_account": "Output IGST - AC" })])), } } fn codes(w: &[Warning]) -> Vec<&str> { w.iter().map(|w| w.code.as_str()).collect() } #[test] fn versions_parse_leniently() { assert_eq!(Version::parse("15.73.2"), Some(Version { major: 15, minor: 73, patch: 2 })); assert_eq!(Version::parse("v16.0.0-dev"), Some(Version { major: 16, minor: 0, patch: 0 })); assert_eq!(Version::parse("15.1"), Some(Version { major: 15, minor: 1, patch: 0 })); assert_eq!(Version::parse("15.73.0-beta.2"), Some(Version { major: 15, minor: 73, patch: 0 })); assert_eq!(Version::parse("unknown"), None); } #[test] fn feature_switches_follow_the_frappe_version_and_ic_presence() { let v = |frappe: &str, ic: bool| ServerVersions { frappe: Some(frappe.into()), erpnext: None, india_compliance: ic.then(|| "15.0.0".into()), }; assert!(!feature_switches(&v("15.72.9", false)).v2_naming); assert!(feature_switches(&v("15.73.0", false)).v2_naming); assert!(feature_switches(&v("16.0.0-dev", true)).v2_naming); assert!(feature_switches(&v("16.0.0-dev", true)).india_compliance); assert!(!feature_switches(&ServerVersions::default()).v2_naming); } #[test] fn get_versions_response_is_parsed() { let v = parse_versions(&json!({ "message": { "frappe": { "title": "Frappe Framework", "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" }, "hrms": { "version": "15.0.0" }, }})); assert_eq!(v.frappe.as_deref(), Some("15.73.0")); assert_eq!(v.erpnext.as_deref(), Some("15.121.6")); assert_eq!(v.india_compliance.as_deref(), Some("15.9.0")); assert!(parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } })).india_compliance.is_none()); } #[test] fn india_compliance_number_rule() { for ok in ["AP/2026-001", "A", "1234567890123456", "AP-24-25/0001"] { assert!(ic_number_ok(ok), "{ok}"); } for bad in ["", "12345678901234567", "_AB", "AB 01", "AB_01", "-AB", "/AB", "AB#1"] { assert!(!ic_number_ok(bad), "{bad}"); } } #[test] fn naming_series_is_expanded_before_measuring() { assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5); assert_eq!(naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"), 9 + 4 + 1 + 5); assert_eq!(naming_series_expanded_len("INV-.MM.-.DD.-.####"), 4 + 2 + 1 + 2 + 1 + 4); assert_eq!(naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"), 15 + 4 + 1 + 5); } #[test] fn naming_series_options_come_from_getdoctype() { let resp = json!({ "docs": [{ "fields": [ { "fieldname": "customer", "options": "Customer" }, { "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" } ]}]}); let items = parse_naming_series(&resp); assert_eq!(items.iter().map(|i| i.name.as_str()).collect::>(), ["SINV-.YY.-", "ACC-SINV-.YYYY.-"]); assert!(parse_naming_series(&json!({})).is_empty()); } #[test] fn a_healthy_ic_setup_has_no_warnings() { let local = LocalFacts { vendor_registered: true, invalid_numbers: vec![], next_number: Some("AP/2026-001".into()) }; assert_eq!(build_warnings(&cfg(), &local, &ic_versions("15.73.0")), vec![]); } #[test] fn gst_accounts_not_configured_or_mismatched_are_flagged() { let local = LocalFacts { vendor_registered: true, ..Default::default() }; let mut remote = ic_versions("15.73.0"); remote.gst_accounts = Some(Ok(vec![])); assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_accounts_not_configured"]); let mut remote = ic_versions("15.73.0"); remote.gst_accounts = Some(Ok(vec![json!({ "company": "Arun Co", "account_type": "Output", "cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" })])); let w = build_warnings(&cfg(), &local, &remote); assert_eq!(codes(&w), ["gst_account_mismatch"]); assert!(w[0].message.contains("SGST")); let mut remote = ic_versions("15.73.0"); remote.gst_accounts = Some(Err("HTTP 403".into())); assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_settings_unreadable"]); } #[test] fn long_numbers_and_series_are_flagged_under_ic_only() { let local = LocalFacts { vendor_registered: false, invalid_numbers: vec!["INVOICE/2026/000001".into()], next_number: Some("INVOICE/2026/000002".into()), }; let w = build_warnings(&cfg(), &local, &ic_versions("15.73.0")); assert_eq!(codes(&w), ["number_invalid", "number_invalid"]); // Plain ERPNext has no such rule. let mut plain = ic_versions("15.73.0"); plain.versions.as_mut().unwrap().india_compliance = None; assert!(build_warnings(&cfg(), &local, &plain).is_empty()); let mut series = cfg(); series.naming_mode = NamingMode::Series; series.naming_series = "LONGPREFIX-ABC-.YYYY.-.#####".into(); let w = build_warnings(&series, &LocalFacts::default(), &ic_versions("15.73.0")); assert_eq!(codes(&w), ["naming_series_too_long"]); } #[test] fn missing_company_address_and_old_frappe_are_flagged() { let mut c = cfg(); c.company_address.clear(); let w = build_warnings(&c, &LocalFacts::default(), &ic_versions("15.60.0")); assert_eq!(codes(&w), ["mirror_unsupported", "company_address_missing"]); let mut remote = ic_versions("15.73.0"); remote.company_address_count = Some(0); assert_eq!(codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)), ["company_address_missing"]); } #[test] fn required_settings_and_unknown_versions_are_flagged() { let w = build_warnings(&ErpnextConfig::default(), &LocalFacts { vendor_registered: true, ..Default::default() }, &RemoteFacts::default()); assert_eq!(codes(&w), ["versions_unknown", "company_missing", "income_account_missing", "tax_accounts_missing"]); } #[test] fn registered_vendor_without_ic_gets_a_notice() { let mut remote = ic_versions("15.73.0"); remote.versions.as_mut().unwrap().india_compliance = None; let w = build_warnings(&cfg(), &LocalFacts { vendor_registered: true, ..Default::default() }, &remote); assert_eq!(codes(&w), ["ic_not_detected"]); } // ---- against the mock server ---- use crate::integrations::erpnext::testutil::{client, reply, serve_fn, Reply}; fn site(line: &str) -> Reply { let path = line.split_whitespace().nth(1).unwrap_or(""); if path.contains("get_logged_user") { reply(200, json!({ "message": "integration@example.com" })) } else if path.contains("get_versions") { reply(200, json!({ "message": { "frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" } }})) } else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") { reply(200, json!({ "data": { "gst_accounts": [] } })) } 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", "gstin": "27AAPFU0939F1ZV" } ]})) } else if path.starts_with("/api/resource/Company?") { reply(200, json!({ "data": [{ "name": "Arun Co", "company_name": "Arun Co", "abbr": "AC", "default_currency": "INR" }] })) } else if path.starts_with("/api/resource/Account?") && path.contains("Income") { reply(200, json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] })) } else if path.starts_with("/api/resource/Account?") && path.contains("Tax") { reply(200, json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] })) } else if path.starts_with("/api/resource/Price%20List?") { reply(403, json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" })) } else if path.contains("getdoctype") { reply(200, json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] })) } else if path.starts_with("/api/resource/UOM?") { reply(200, json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] })) } else { reply(200, json!({ "data": [] })) } } #[tokio::test] async fn connection_test_reports_user_versions_ic_and_warnings() { let mock = serve_fn(|_, line| site(line)).await; let local = LocalFacts { vendor_registered: true, next_number: Some("AP/2026-001".into()), ..Default::default() }; let result = test_connection(&client(&mock), &cfg(), &local).await.unwrap(); assert_eq!(result.user, "integration@example.com"); assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0")); assert!(result.india_compliance && result.features.v2_naming); assert_eq!(codes(&result.warnings), ["gst_accounts_not_configured"]); let json = serde_json::to_value(&result).unwrap(); assert_eq!(json["indiaCompliance"], true); assert_eq!(json["features"]["v2Naming"], true); } #[tokio::test] async fn company_addresses_fall_back_when_the_site_has_no_gstin_field() { // A plain ERPNext (no India Compliance) rejects the custom `gstin` field with a 417. let mock = serve_fn(|_, line| { let path = line.split_whitespace().nth(1).unwrap_or(""); if path.starts_with("/api/resource/Address?") && path.contains("gstin") { reply(417, json!({ "exc_type": "ValidationError", "_error_message": "Field not permitted in query: gstin" })) } else if path.starts_with("/api/resource/Address?") { reply(200, json!({ "data": [{ "name": "Arun Co-Billing", "address_title": "Arun Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001" }] })) } else { reply(200, json!({ "data": [] })) } }) .await; let options = client(&mock).load_options("Arun Co").await.unwrap(); assert_eq!(options.company_addresses.len(), 1); assert_eq!(options.company_addresses[0].name, "Arun Co-Billing"); assert!(options.errors.iter().all(|e| e.list != "companyAddresses"), "{:?}", options.errors); } #[tokio::test] async fn connection_test_fails_for_the_guest_user_and_bad_credentials() { let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await; let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err(); assert_eq!(err.kind, ErrorKind::Auth); let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await; let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err(); assert_eq!(err.kind, ErrorKind::Auth); assert!(!err.to_string().contains("thesecret")); } #[tokio::test] async fn options_load_per_list_and_one_failure_does_not_hide_the_rest() { let mock = serve_fn(|_, line| site(line)).await; let options = client(&mock).load_options("Arun Co").await.unwrap(); assert_eq!(options.companies.len(), 1); assert_eq!(options.companies[0].name, "Arun Co"); assert_eq!(options.companies[0].detail, "AC · INR"); assert_eq!(options.company_addresses[0].name, "Arun Co-Billing"); assert!(options.company_addresses[0].detail.contains("Mumbai")); assert_eq!(options.income_accounts[0].name, "Sales - AC"); assert_eq!(options.tax_accounts[0].name, "Output CGST - AC"); assert_eq!(options.naming_series[0].name, "SINV-.YY.-"); assert_eq!(options.uoms.iter().map(|u| u.name.as_str()).collect::>(), ["Nos", "Hour"]); assert!(options.price_lists.is_empty()); assert_eq!(options.errors.len(), 1); assert_eq!(options.errors[0].list, "priceLists"); assert!(options.errors[0].message.contains("No permission for Price List")); // Company-scoped lists are skipped until a company is chosen. let none = client(&mock).load_options(" ").await.unwrap(); assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty()); let requests = mock.requests.lock().unwrap().join("\n"); assert!(requests.contains("root_type"), "income accounts are filtered by root type"); assert!(requests.contains("limit_page_length=0")); } #[tokio::test] async fn options_abort_on_authentication_failure() { let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await; let err = client(&mock).load_options("Arun Co").await.unwrap_err(); assert_eq!(err.kind, ErrorKind::Auth); assert_eq!(mock.hits.load(std::sync::atomic::Ordering::SeqCst), 1, "stops at the first auth failure"); } }