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.
862 lines
37 KiB
Rust
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");
|
|
}
|
|
}
|