Files
Voiced/src-tauri/src/integrations/erpnext/discovery.rs
T
xavierk 18762e42cb 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.
2026-10-04 22:09:03 +05:30

862 lines
37 KiB
Rust

//! 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<Version> {
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<String>,
pub erpnext: Option<String>,
pub india_compliance: Option<String>,
}
#[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<String> {
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<OptionItem>,
pub company_addresses: Vec<OptionItem>,
pub income_accounts: Vec<OptionItem>,
pub tax_accounts: Vec<OptionItem>,
pub tax_templates: Vec<OptionItem>,
pub naming_series: Vec<OptionItem>,
pub item_groups: Vec<OptionItem>,
pub uoms: Vec<OptionItem>,
pub cost_centers: Vec<OptionItem>,
pub price_lists: Vec<OptionItem>,
pub customer_groups: Vec<OptionItem>,
pub territories: Vec<OptionItem>,
/// Lists that could not be loaded (one failing list does not hide the rest).
pub errors: Vec<OptionError>,
}
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<OptionItem> {
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::<Vec<_>>().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<OptionItem> {
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<String, ErpError> {
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<ServerVersions, ErpError> {
let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?;
Ok(parse_versions(&v))
}
/// `GET /api/resource/<doctype>` with every row (`limit_page_length=0`).
pub async fn list_resource(
&self,
doctype: &str,
fields: &[&str],
filters: Value,
order_by: &str,
) -> Result<Vec<Value>, 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<ErpnextOptions, ErpError> {
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<String>) -> 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<String>,
/// The number the next invoice would get, if a series is active.
pub next_number: Option<String>,
}
/// What was learned from the site. `None` means the check could not run.
#[derive(Debug, Clone, Default)]
pub struct RemoteFacts {
pub versions: Option<ServerVersions>,
pub company_address_count: Option<usize>,
/// Rows of GST Settings -> gst_accounts, or the reason they could not be read.
pub gst_accounts: Option<Result<Vec<Value>, 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<Warning> {
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<Warning> {
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<Warning>,
}
/// 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<ConnectionTest, ErpError> {
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::<Vec<_>>(), ["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::<Vec<_>>(), ["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");
}
}