Add the ERPNext integration foundation (Phase F1)

- New integrations module with an InvoiceSink seam and an ERPNext client built on reqwest with rustls (ring provider only; aws-lc is not in the dependency graph). Token auth, timeouts, retry with backoff on 429, 5xx and timeouts (POSTs only when idempotent), optional extra CA, and a rule that plain http is allowed only for localhost-style hosts.
- Error extraction for Frappe v1 and v2 bodies, version and India Compliance discovery, live option loaders, and a connection test that returns warnings instead of failing.
- Pure, snapshot-tested mapping from a stored invoice to a Sales Invoice (CGST+SGST, IGST, unregistered, discount, code-less rows, mirrored and series naming) that refuses to produce a payload whose totals differ from Voiced's.
- Migration M10 adds erpnext_config (the API secret is never returned to the webview), erpnext_sync and the nullable ERPNext link columns.
- Commands to get and save the config, test the connection and load options, with typed wrappers. No UI and no push yet.

The API secret is stored in the SQLite file, so backups contain it. Nothing has run against a live ERPNext yet.
This commit is contained in:
2026-10-04 19:50:24 +05:30
parent 258e27b178
commit e0ba578541
16 changed files with 4254 additions and 9 deletions
@@ -0,0 +1,840 @@
//! 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 {
self.list_resource(
"Address",
&["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"],
json!([
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", company],
["disabled", "=", 0]
]),
"name asc",
)
.await
.map(|rows| {
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 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");
}
}