Merge origin/main (CI quality gates) into the 1.1.0 release

# Conflicts:
#	package-lock.json
#	src/views/NewInvoice.tsx
This commit is contained in:
2026-10-06 09:43:47 +05:30
52 changed files with 6100 additions and 1080 deletions
+224 -52
View File
@@ -49,7 +49,10 @@ impl Default for ClientOptions {
/// Plain http is only for a local development site; anything else must use https.
pub fn is_local_dev_host(host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase();
host == "localhost" || host == "127.0.0.1" || host.ends_with(".localhost") || host.ends_with(".test")
host == "localhost"
|| host == "127.0.0.1"
|| host.ends_with(".localhost")
|| host.ends_with(".test")
}
/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash.
@@ -63,10 +66,15 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
raw.to_string()
} else {
let host = raw.split(['/', ':']).next().unwrap_or("");
let scheme = if is_local_dev_host(host) { "http" } else { "https" };
let scheme = if is_local_dev_host(host) {
"http"
} else {
"https"
};
format!("{scheme}://{raw}")
};
let mut url = Url::parse(&with_scheme).map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
let mut url = Url::parse(&with_scheme)
.map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
let host = url
.host_str()
.ok_or_else(|| ErpError::config("The ERPNext address has no host name."))?
@@ -80,10 +88,16 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
));
}
}
other => return Err(ErpError::config(format!("Unsupported address scheme \"{other}\"; use https://."))),
other => {
return Err(ErpError::config(format!(
"Unsupported address scheme \"{other}\"; use https://."
)))
}
}
if !url.username().is_empty() || url.password().is_some() {
return Err(ErpError::config("Do not put credentials in the address; use the API key and secret fields."));
return Err(ErpError::config(
"Do not put credentials in the address; use the API key and secret fields.",
));
}
url.set_query(None);
url.set_fragment(None);
@@ -114,7 +128,9 @@ pub struct ErpClient {
impl std::fmt::Debug for ErpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ErpClient").field("base", &self.base.as_str()).finish_non_exhaustive()
f.debug_struct("ErpClient")
.field("base", &self.base.as_str())
.finish_non_exhaustive()
}
}
@@ -127,12 +143,18 @@ impl ErpClient {
opts: ClientOptions,
) -> Result<Self, ErpError> {
ensure_crypto_provider();
let base = Url::parse(&normalize_base_url(base_url)?).map_err(|e| ErpError::config(e.to_string()))?;
let base = Url::parse(&normalize_base_url(base_url)?)
.map_err(|e| ErpError::config(e.to_string()))?;
if api_key.trim().is_empty() || !api_secret.is_set() {
return Err(ErpError::config("Enter the API key and API secret."));
}
let mut auth = HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose()))
.map_err(|_| ErpError::config("The API key or secret contains characters that cannot be sent."))?;
let mut auth =
HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose()))
.map_err(|_| {
ErpError::config(
"The API key or secret contains characters that cannot be sent.",
)
})?;
auth.set_sensitive(true);
let mut headers = HeaderMap::new();
@@ -152,13 +174,18 @@ impl ErpClient {
let certs = reqwest::Certificate::from_pem_bundle(extra_ca_pem.trim().as_bytes())
.map_err(|_| ErpError::config("The extra CA certificate is not valid PEM."))?;
if certs.is_empty() {
return Err(ErpError::config("The extra CA certificate field holds no certificate."));
return Err(ErpError::config(
"The extra CA certificate field holds no certificate.",
));
}
builder = builder.tls_certs_merge(certs);
}
let http = builder
.build()
.map_err(|e| ErpError::config(format!("Could not set up the HTTP client: {}", error_chain(&e))))?;
let http = builder.build().map_err(|e| {
ErpError::config(format!(
"Could not set up the HTTP client: {}",
error_chain(&e)
))
})?;
Ok(ErpClient {
http,
base,
@@ -168,7 +195,13 @@ impl ErpClient {
}
pub fn from_config(cfg: &ErpnextConfig) -> Result<Self, ErpError> {
Self::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, &cfg.extra_ca_pem, ClientOptions::default())
Self::new(
&cfg.base_url,
&cfg.api_key,
&cfg.api_secret,
&cfg.extra_ca_pem,
ClientOptions::default(),
)
}
pub fn base_url(&self) -> &str {
@@ -184,22 +217,38 @@ impl ErpClient {
}
/// GET, retried on 429, 5xx and timeouts.
pub async fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result<Value, ErpError> {
self.send(Method::GET, segments, query, Payload::None, true).await
pub async fn get(
&self,
segments: &[&str],
query: &[(&str, String)],
) -> Result<Value, ErpError> {
self.send(Method::GET, segments, query, Payload::None, true)
.await
}
/// POST with a JSON body. 429 is always retried (the server refused before doing anything). 5xx and
/// timeouts are retried only when `idempotent` is true, because the server may have processed the
/// request: a mirrored-name Sales Invoice is idempotent (a repeat is a 409), a series-named one is not.
pub async fn post(&self, segments: &[&str], body: &Value, idempotent: bool) -> Result<Value, ErpError> {
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent).await
pub async fn post(
&self,
segments: &[&str],
body: &Value,
idempotent: bool,
) -> Result<Value, ErpError> {
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent)
.await
}
/// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file
/// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or
/// timeout, because an upload that may have landed would attach the file twice.
pub async fn post_file(&self, segments: &[&str], upload: &Upload<'_>) -> Result<Value, ErpError> {
self.send(Method::POST, segments, &[], Payload::Upload(upload), false).await
pub async fn post_file(
&self,
segments: &[&str],
upload: &Upload<'_>,
) -> Result<Value, ErpError> {
self.send(Method::POST, segments, &[], Payload::Upload(upload), false)
.await
}
async fn send(
@@ -260,7 +309,8 @@ impl ErpClient {
if status.is_success() {
return self.parse_success(&text);
}
let retryable = status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
let retryable =
status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
if retryable && can_retry {
self.backoff(attempt, retry_after).await;
attempt += 1;
@@ -287,7 +337,10 @@ impl ErpClient {
}
async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) {
let exp = self.opts.backoff_base.saturating_mul(1u32 << attempt.min(10));
let exp = self
.opts
.backoff_base
.saturating_mul(1u32 << attempt.min(10));
let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff);
tokio::time::sleep(delay).await;
}
@@ -311,7 +364,10 @@ impl ErpClient {
fn map_transport(&self, e: &reqwest::Error) -> ErpError {
let chain = error_chain(e);
let err = if e.is_timeout() {
ErpError::new(ErrorKind::Timeout, "The ERPNext server did not answer in time.")
ErpError::new(
ErrorKind::Timeout,
"The ERPNext server did not answer in time.",
)
} else if e.is_connect() {
let lower = chain.to_ascii_lowercase();
let hint = if lower.contains("certificate") || lower.contains("unknownissuer") {
@@ -319,9 +375,15 @@ impl ErpClient {
} else {
""
};
ErpError::new(ErrorKind::Network, format!("Could not connect to the ERPNext server ({chain}).{hint}"))
ErpError::new(
ErrorKind::Network,
format!("Could not connect to the ERPNext server ({chain}).{hint}"),
)
} else {
ErpError::new(ErrorKind::Network, format!("The request to ERPNext failed ({chain})."))
ErpError::new(
ErrorKind::Network,
format!("The request to ERPNext failed ({chain})."),
)
};
self.scrub(err)
}
@@ -352,9 +414,18 @@ mod tests {
#[test]
fn https_is_accepted_and_normalised() {
assert_eq!(normalize_base_url(" https://Erp.Example.com/ ").unwrap(), "https://erp.example.com");
assert_eq!(normalize_base_url("erp.example.com").unwrap(), "https://erp.example.com");
assert_eq!(normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(), "https://erp.example.com:8443/sub");
assert_eq!(
normalize_base_url(" https://Erp.Example.com/ ").unwrap(),
"https://erp.example.com"
);
assert_eq!(
normalize_base_url("erp.example.com").unwrap(),
"https://erp.example.com"
);
assert_eq!(
normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(),
"https://erp.example.com:8443/sub"
);
}
#[test]
@@ -370,14 +441,26 @@ mod tests {
let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}"));
assert!(url.starts_with("http://"), "{ok} -> {url}");
}
for bad in ["http://erp.example.com", "http://192.168.1.10:8000", "http://localhost.evil.com", "http://notlocalhost"] {
for bad in [
"http://erp.example.com",
"http://192.168.1.10:8000",
"http://localhost.evil.com",
"http://notlocalhost",
] {
assert!(normalize_base_url(bad).is_err(), "{bad} must be refused");
}
}
#[test]
fn other_schemes_credentials_and_blanks_are_refused() {
for bad in ["ftp://erp.example.com", "file:///etc/passwd", "https://user:pw@erp.example.com", "", " ", "https://"] {
for bad in [
"ftp://erp.example.com",
"file:///etc/passwd",
"https://user:pw@erp.example.com",
"",
" ",
"https://",
] {
assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused");
}
}
@@ -391,8 +474,14 @@ mod tests {
.unwrap();
assert_eq!(value["message"], "Sample@example.com");
let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase();
assert!(raw.starts_with("get /api/method/frappe.auth.get_logged_user "), "{raw}");
assert!(raw.contains("authorization: token thekey:thesecret"), "{raw}");
assert!(
raw.starts_with("get /api/method/frappe.auth.get_logged_user "),
"{raw}"
);
assert!(
raw.contains("authorization: token thekey:thesecret"),
"{raw}"
);
assert!(raw.contains("accept: application/json"), "{raw}");
}
@@ -400,11 +489,19 @@ mod tests {
async fn path_segments_are_percent_encoded_once() {
let mock = serve(vec![reply(200, json!({ "data": [] }))]).await;
client(&mock)
.get(&["api", "resource", "Sales Invoice", "INV/2026-001"], &[("limit_page_length", "0".into())])
.get(
&["api", "resource", "Sales Invoice", "INV/2026-001"],
&[("limit_page_length", "0".into())],
)
.await
.unwrap();
let raw = mock.requests.lock().unwrap()[0].clone();
assert!(raw.starts_with("GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "), "{raw}");
assert!(
raw.starts_with(
"GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "
),
"{raw}"
);
}
#[tokio::test]
@@ -412,15 +509,25 @@ mod tests {
let mut limited = reply(429, json!({ "message": "slow down" }));
limited.headers.push(("Retry-After", "0".into()));
let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await;
let value = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap();
let value = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap();
assert_eq!(value["message"], "ok");
assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn persistent_500_exhausts_the_retries() {
let mock = serve(vec![reply(500, json!({ "exception": "frappe.exceptions.ValidationError: boom" }))]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
let mock = serve(vec![reply(
500,
json!({ "exception": "frappe.exceptions.ValidationError: boom" }),
)])
.await;
let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Server);
assert_eq!(err.message, "boom");
assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries");
@@ -428,9 +535,17 @@ mod tests {
#[tokio::test]
async fn validation_errors_are_not_retried() {
let mock = serve(vec![reply(417, json!({ "_error_message": "Rate is required" }))]).await;
let mock = serve(vec![reply(
417,
json!({ "_error_message": "Rate is required" }),
)])
.await;
let err = client(&mock)
.post(&["api", "resource", "Sales Invoice"], &json!({ "a": 1 }), true)
.post(
&["api", "resource", "Sales Invoice"],
&json!({ "a": 1 }),
true,
)
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Validation);
@@ -440,25 +555,42 @@ mod tests {
#[tokio::test]
async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() {
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) };
let slow = Reply {
delay_ms: 2_000,
..reply(200, json!({}))
};
let mock = serve(vec![slow]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Timeout);
assert_eq!(mock.hits.load(Ordering::SeqCst), 4);
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) };
let slow = Reply {
delay_ms: 2_000,
..reply(200, json!({}))
};
let mock = serve(vec![slow]).await;
let err = client(&mock)
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Timeout);
assert_eq!(mock.hits.load(Ordering::SeqCst), 1, "a write that may have landed is not repeated");
assert_eq!(
mock.hits.load(Ordering::SeqCst),
1,
"a write that may have landed is not repeated"
);
}
#[tokio::test]
async fn non_idempotent_write_retries_429_but_not_500() {
let mock = serve(vec![reply(429, json!({})), reply(200, json!({ "data": { "name": "X" } }))]).await;
let mock = serve(vec![
reply(429, json!({})),
reply(200, json!({ "data": { "name": "X" } })),
])
.await;
let value = client(&mock)
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
.await
@@ -478,11 +610,20 @@ mod tests {
#[tokio::test]
async fn redirects_are_reported_not_followed() {
let mut moved = reply(301, json!({}));
moved.headers.push(("Location", "https://erp.example.com/".into()));
moved
.headers
.push(("Location", "https://erp.example.com/".into()));
let mock = serve(vec![moved]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Protocol);
assert!(err.message.contains("https://erp.example.com/"), "{}", err.message);
assert!(
err.message.contains("https://erp.example.com/"),
"{}",
err.message
);
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
}
@@ -491,7 +632,10 @@ mod tests {
let mut page = reply(200, json!({}));
page.body = "<html>login</html>".into();
let mock = serve(vec![page]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Protocol);
}
@@ -500,7 +644,14 @@ mod tests {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let c = ErpClient::new(&format!("http://127.0.0.1:{port}"), "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap();
let c = ErpClient::new(
&format!("http://127.0.0.1:{port}"),
"thekey",
&Secret::new("thesecret"),
"",
fast_opts(),
)
.unwrap();
let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::Network);
assert!(!err.to_string().contains("thesecret"));
@@ -509,9 +660,30 @@ mod tests {
#[test]
fn credentials_are_required_and_a_bad_ca_is_refused() {
assert!(ErpClient::new("https://erp.example.com", "", &Secret::new("s"), "", fast_opts()).is_err());
assert!(ErpClient::new("https://erp.example.com", "k", &Secret::default(), "", fast_opts()).is_err());
let err = ErpClient::new("https://erp.example.com", "k", &Secret::new("s"), "not a certificate", fast_opts()).unwrap_err();
assert!(ErpClient::new(
"https://erp.example.com",
"",
&Secret::new("s"),
"",
fast_opts()
)
.is_err());
assert!(ErpClient::new(
"https://erp.example.com",
"k",
&Secret::default(),
"",
fast_opts()
)
.is_err());
let err = ErpClient::new(
"https://erp.example.com",
"k",
&Secret::new("s"),
"not a certificate",
fast_opts(),
)
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Config);
}
}
+57 -17
View File
@@ -29,7 +29,11 @@ impl Secret {
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(if self.is_set() { "Secret([redacted])" } else { "Secret(unset)" })
f.write_str(if self.is_set() {
"Secret([redacted])"
} else {
"Secret(unset)"
})
}
}
@@ -141,7 +145,9 @@ impl ErpnextConfig {
if let Some(u) = self.uom_map.get(unit).filter(|u| !u.trim().is_empty()) {
return u.trim().to_string();
}
default_uom_map().remove(unit).unwrap_or_else(|| "Nos".to_string())
default_uom_map()
.remove(unit)
.unwrap_or_else(|| "Nos".to_string())
}
pub fn view(&self) -> ErpnextConfigView {
@@ -173,7 +179,8 @@ impl ErpnextConfig {
attach_pdf: self.attach_pdf,
auto_push_on_issue: self.auto_push_on_issue,
create_missing_customers: self.create_missing_customers,
last_detect_result: serde_json::from_str(&self.last_detect_result).unwrap_or(Value::Null),
last_detect_result: serde_json::from_str(&self.last_detect_result)
.unwrap_or(Value::Null),
}
}
}
@@ -287,7 +294,11 @@ fn yes() -> bool {
}
fn origin_of(url: &str) -> String {
url.split('/').take(3).collect::<Vec<_>>().join("/").to_ascii_lowercase()
url.split('/')
.take(3)
.collect::<Vec<_>>()
.join("/")
.to_ascii_lowercase()
}
impl ErpnextConfigInput {
@@ -460,8 +471,11 @@ mod tests {
use super::*;
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
let mut base = serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" });
base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let mut base =
serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" });
base.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
serde_json::from_value(base).unwrap()
}
@@ -471,7 +485,10 @@ mod tests {
#[test]
fn debug_never_prints_the_secret() {
let cfg = ErpnextConfig { api_secret: Secret::new("s3cr3t-value"), ..Default::default() };
let cfg = ErpnextConfig {
api_secret: Secret::new("s3cr3t-value"),
..Default::default()
};
assert!(!format!("{cfg:?}").contains("s3cr3t-value"));
assert!(!format!("{:?}", Secret::new("s3cr3t-value")).contains("s3cr3t-value"));
}
@@ -480,7 +497,12 @@ mod tests {
fn defaults_load_from_the_migration_row() {
let cfg = load(&db()).unwrap();
assert_eq!(cfg.naming_mode, NamingMode::Mirror);
assert!(!cfg.submit_on_push && cfg.attach_pdf && !cfg.auto_push_on_issue && cfg.create_missing_customers);
assert!(
!cfg.submit_on_push
&& cfg.attach_pdf
&& !cfg.auto_push_on_issue
&& cfg.create_missing_customers
);
assert_eq!(cfg.uom_for("hour"), "Hour");
assert_eq!(cfg.uom_for("session"), "Nos");
assert!(!cfg.api_secret.is_set());
@@ -510,34 +532,52 @@ mod tests {
#[test]
fn blank_secret_keeps_and_explicit_values_replace_or_clear() {
let conn = db();
let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap();
let first = input(serde_json::json!({ "apiSecret": "one" }))
.resolve(&load(&conn).unwrap())
.unwrap();
save(&conn, &first).unwrap();
let keep = input(serde_json::json!({ "apiSecret": " " })).resolve(&load(&conn).unwrap()).unwrap();
let keep = input(serde_json::json!({ "apiSecret": " " }))
.resolve(&load(&conn).unwrap())
.unwrap();
assert_eq!(keep.api_secret.expose(), "one");
let replace = input(serde_json::json!({ "apiSecret": "two" })).resolve(&load(&conn).unwrap()).unwrap();
let replace = input(serde_json::json!({ "apiSecret": "two" }))
.resolve(&load(&conn).unwrap())
.unwrap();
assert_eq!(replace.api_secret.expose(), "two");
let clear = input(serde_json::json!({ "clearSecret": true })).resolve(&load(&conn).unwrap()).unwrap();
let clear = input(serde_json::json!({ "clearSecret": true }))
.resolve(&load(&conn).unwrap())
.unwrap();
assert!(!clear.api_secret.is_set());
}
#[test]
fn stored_secret_is_not_reused_for_another_server() {
let conn = db();
let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap();
let first = input(serde_json::json!({ "apiSecret": "one" }))
.resolve(&load(&conn).unwrap())
.unwrap();
save(&conn, &first).unwrap();
let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" })).resolve(&load(&conn).unwrap());
let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" }))
.resolve(&load(&conn).unwrap());
assert!(moved.unwrap_err().contains("API secret"));
// The same server with a different path or case is still the same origin.
let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" })).resolve(&load(&conn).unwrap());
let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" }))
.resolve(&load(&conn).unwrap());
assert!(same.is_ok());
}
#[test]
fn invalid_address_or_naming_mode_is_rejected() {
let stored = ErpnextConfig::default();
assert!(input(serde_json::json!({ "baseUrl": "http://erp.example.com" })).resolve(&stored).is_err());
assert!(input(serde_json::json!({ "namingMode": "random" })).resolve(&stored).is_err());
assert!(
input(serde_json::json!({ "baseUrl": "http://erp.example.com" }))
.resolve(&stored)
.is_err()
);
assert!(input(serde_json::json!({ "namingMode": "random" }))
.resolve(&stored)
.is_err());
}
#[test]
+466 -103
View File
@@ -20,12 +20,19 @@ 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 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 })
Some(Version {
major,
minor,
patch,
})
}
}
@@ -59,7 +66,10 @@ 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 = entry
.get("version")
.and_then(Value::as_str)
.or_else(|| entry.as_str())?;
let v = v.trim();
(!v.is_empty()).then(|| v.to_string())
};
@@ -77,7 +87,9 @@ 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 };
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 == '/')
@@ -158,13 +170,22 @@ fn item(row: &Value, label_key: &str, detail: String) -> Option<OptionItem> {
let label = text(row, label_key);
Some(OptionItem {
name: name.to_string(),
label: if label.is_empty() { name.to_string() } else { label.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(" · ")
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.
@@ -173,23 +194,41 @@ pub fn parse_naming_series(doctype_response: &Value) -> Vec<OptionItem> {
.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 {
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)) })
.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();
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,
@@ -202,7 +241,12 @@ impl ErpClient {
}
pub async fn get_versions(&self) -> Result<ServerVersions, ErpError> {
let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?;
let v = self
.get(
&["api", "method", "frappe.utils.change_log.get_versions"],
&[],
)
.await?;
Ok(parse_versions(&v))
}
@@ -223,7 +267,10 @@ impl ErpClient {
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())
Ok(v.get("data")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default())
}
pub async fn load_options(&self, company: &str) -> Result<ErpnextOptions, ErpError> {
@@ -234,52 +281,119 @@ impl ErpClient {
($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) => {
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() }),
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()
})
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())
.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())
.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())
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())
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())
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))
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() {
@@ -291,11 +405,32 @@ impl ErpClient {
["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"];
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?,
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>(
@@ -304,7 +439,13 @@ impl ErpClient {
item(
r,
"address_title",
join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]),
join(&[
text(r, "address_line1"),
text(r, "city"),
text(r, "state"),
text(r, "pincode"),
text(r, "gstin"),
]),
)
})
.collect(),
@@ -314,21 +455,39 @@ impl ErpClient {
self.list_resource(
"Account",
&["name", "account_name", "account_type"],
json!([["company", "=", company], ["root_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]),
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())
.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]]),
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())
.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(
@@ -338,17 +497,29 @@ impl ErpClient {
"name asc",
)
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "title", String::new())).collect())
.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]]),
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())
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "cost_center_name", String::new()))
.collect()
})
});
Ok(out)
}
@@ -363,7 +534,10 @@ pub struct Warning {
}
fn warn(code: &str, message: impl Into<String>) -> Warning {
Warning { code: code.to_string(), message: message.into() }
Warning {
code: code.to_string(),
message: message.into(),
}
}
/// Facts from the local database that the warnings need.
@@ -390,7 +564,9 @@ pub struct RemoteFacts {
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"))
.filter(|r| {
text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output")
})
.collect();
if for_company.is_empty() {
return vec![warn(
@@ -419,7 +595,11 @@ pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
}
/// 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> {
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);
@@ -449,11 +629,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa
}
}
} else if cfg.naming_series.is_empty() {
w.push(warn("naming_series_missing", "ERPNext series naming is selected but no naming series is chosen."));
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() {
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.",
@@ -479,9 +663,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa
}
}
if cfg.company_address.is_empty() {
w.push(warn("company_address_missing", "India Compliance needs a company address on every invoice; none is selected."));
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."));
w.push(warn(
"company_address_missing",
"No address is linked to the company on the site.",
));
}
match cfg.naming_mode {
NamingMode::Mirror => {
@@ -533,14 +723,21 @@ pub struct ConnectionTest {
/// 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> {
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 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() {
@@ -548,7 +745,11 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
.list_resource(
"Address",
&["name"],
json!([["Dynamic Link", "link_doctype", "=", "Company"], ["Dynamic Link", "link_name", "=", cfg.company], ["disabled", "=", 0]]),
json!([
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", cfg.company],
["disabled", "=", 0]
]),
"name asc",
)
.await
@@ -560,7 +761,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
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(|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()),
);
}
@@ -568,7 +775,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
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 })
Ok(ConnectionTest {
user,
india_compliance: features.india_compliance,
features,
versions,
warnings,
})
}
#[cfg(test)]
@@ -608,10 +821,38 @@ mod tests {
#[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("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);
}
@@ -640,7 +881,11 @@ mod tests {
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());
assert!(
parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } }))
.india_compliance
.is_none()
);
}
#[test]
@@ -648,7 +893,16 @@ mod tests {
for ok in ["INV/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"] {
for bad in [
"",
"12345678901234567",
"_AB",
"AB 01",
"AB_01",
"-AB",
"/AB",
"AB#1",
] {
assert!(!ic_number_ok(bad), "{bad}");
}
}
@@ -656,9 +910,18 @@ mod tests {
#[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);
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]
@@ -668,33 +931,54 @@ mod tests {
{ "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_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("INV/2026-001".into()) };
assert_eq!(build_warnings(&cfg(), &local, &ic_versions("15.73.0")), vec![]);
let local = LocalFacts {
vendor_registered: true,
invalid_numbers: vec![],
next_number: Some("INV/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 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"]);
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": "Test Co", "account_type": "Output",
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" })]));
remote.gst_accounts = Some(Ok(vec![
json!({ "company": "Test 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"]);
assert_eq!(
codes(&build_warnings(&cfg(), &local, &remote)),
["gst_settings_unreadable"]
);
}
#[test]
@@ -727,20 +1011,45 @@ mod tests {
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"]);
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"]);
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);
let w = build_warnings(
&cfg(),
&LocalFacts {
vendor_registered: true,
..Default::default()
},
&remote,
);
assert_eq!(codes(&w), ["ic_not_detected"]);
}
@@ -753,27 +1062,51 @@ mod tests {
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" }
}}))
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": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" }
]}))
reply(
200,
json!({ "data": [
{ "name": "Test Co-Billing", "address_title": "Test 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": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }))
reply(
200,
json!({ "data": [{ "name": "Test Co", "company_name": "Test 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": "" }] }))
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" }] }))
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" }))
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.-" }] }] }))
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": "" }] }))
reply(
200,
json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] }),
)
} else {
reply(200, json!({ "data": [] }))
}
@@ -782,8 +1115,14 @@ mod tests {
#[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("INV/2026-001".into()), ..Default::default() };
let result = test_connection(&client(&mock), &cfg(), &local).await.unwrap();
let local = LocalFacts {
vendor_registered: true,
next_number: Some("INV/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);
@@ -810,17 +1149,25 @@ mod tests {
let options = client(&mock).load_options("Test Co").await.unwrap();
assert_eq!(options.company_addresses.len(), 1);
assert_eq!(options.company_addresses[0].name, "Test Co-Billing");
assert!(options.errors.iter().all(|e| e.list != "companyAddresses"), "{:?}", options.errors);
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();
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();
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default())
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Auth);
assert!(!err.to_string().contains("thesecret"));
}
@@ -837,17 +1184,29 @@ mod tests {
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_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"));
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("root_type"),
"income accounts are filtered by root type"
);
assert!(requests.contains("limit_page_length=0"));
}
@@ -856,6 +1215,10 @@ mod tests {
let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await;
let err = client(&mock).load_options("Test 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");
assert_eq!(
mock.hits.load(std::sync::atomic::Ordering::SeqCst),
1,
"stops at the first auth failure"
);
}
}
+132 -31
View File
@@ -81,7 +81,12 @@ impl From<ErpError> for String {
impl ErpError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
ErpError { kind, status: None, message: message.into(), exc_type: None }
ErpError {
kind,
status: None,
message: message.into(),
exc_type: None,
}
}
pub fn config(message: impl Into<String>) -> Self {
@@ -99,8 +104,15 @@ impl ErpError {
if parsed.exc_type.as_deref() == Some("DuplicateEntryError") {
kind = ErrorKind::Duplicate;
}
let message = parsed.message.unwrap_or_else(|| default_status_text(status));
ErpError { kind, status: Some(status), message, exc_type: parsed.exc_type }
let message = parsed
.message
.unwrap_or_else(|| default_status_text(status));
ErpError {
kind,
status: Some(status),
message,
exc_type: parsed.exc_type,
}
}
/// Removes every occurrence of the given secrets from the message.
@@ -150,14 +162,24 @@ const MAX_MESSAGE_CHARS: usize = 600;
pub fn parse_error_body(body: &str) -> ParsedError {
let Ok(json) = serde_json::from_str::<Value>(body) else {
let text = strip_html(body);
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim();
return ParsedError { message: non_empty(truncate(first)), exc_type: None };
let first = text
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
return ParsedError {
message: non_empty(truncate(first)),
exc_type: None,
};
};
let Some(obj) = json.as_object() else {
return ParsedError::default();
};
let mut exc_type = obj.get("exc_type").and_then(Value::as_str).map(str::to_string);
let mut exc_type = obj
.get("exc_type")
.and_then(Value::as_str)
.map(str::to_string);
let mut messages: Vec<String> = Vec::new();
if let Some(errors) = obj.get("errors").and_then(Value::as_array) {
@@ -165,8 +187,15 @@ pub fn parse_error_body(body: &str) -> ParsedError {
match e {
Value::String(s) => push_unique(&mut messages, strip_html(s)),
Value::Object(o) => {
let text = o.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty());
let exception = o.get("exception").and_then(Value::as_str).map(split_exception);
let text = o
.get("message")
.and_then(Value::as_str)
.map(strip_html)
.filter(|m| !m.is_empty());
let exception = o
.get("exception")
.and_then(Value::as_str)
.map(split_exception);
if exc_type.is_none() {
exc_type = o
.get("type")
@@ -174,7 +203,9 @@ pub fn parse_error_body(body: &str) -> ParsedError {
.map(str::to_string)
.or_else(|| exception.as_ref().and_then(|(t, _)| t.clone()));
}
if let Some(m) = text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty())) {
if let Some(m) =
text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty()))
{
push_unique(&mut messages, m);
}
}
@@ -191,7 +222,12 @@ pub fn parse_error_body(body: &str) -> ParsedError {
}
}
if messages.is_empty() {
if let Some(m) = obj.get("_error_message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) {
if let Some(m) = obj
.get("_error_message")
.and_then(Value::as_str)
.map(strip_html)
.filter(|m| !m.is_empty())
{
messages.push(m);
}
}
@@ -207,7 +243,12 @@ pub fn parse_error_body(body: &str) -> ParsedError {
}
}
if messages.is_empty() {
if let Some(m) = obj.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) {
if let Some(m) = obj
.get("message")
.and_then(Value::as_str)
.map(strip_html)
.filter(|m| !m.is_empty())
{
messages.push(m);
}
}
@@ -218,10 +259,16 @@ pub fn parse_error_body(body: &str) -> ParsedError {
}
if exc_type.is_none() {
// v1 on Frappe 14 has no `exc_type`; the class is still named in `exception`.
exc_type = obj.get("exception").and_then(Value::as_str).and_then(|e| split_exception(e).0);
exc_type = obj
.get("exception")
.and_then(Value::as_str)
.and_then(|e| split_exception(e).0);
}
ParsedError { message: non_empty(truncate(&messages.join("; "))), exc_type }
ParsedError {
message: non_empty(truncate(&messages.join("; "))),
exc_type,
}
}
fn server_messages(raw: &str) -> Vec<String> {
@@ -233,10 +280,18 @@ fn server_messages(raw: &str) -> Vec<String> {
.filter_map(|item| {
let text = match item {
Value::String(s) => match serde_json::from_str::<Value>(s) {
Ok(Value::Object(o)) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
Ok(Value::Object(o)) => o
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
_ => s.clone(),
},
Value::Object(o) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
Value::Object(o) => o
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
_ => String::new(),
};
let text = strip_html(&text);
@@ -250,18 +305,28 @@ fn server_messages(raw: &str) -> Vec<String> {
fn split_exception(raw: &str) -> (Option<String>, String) {
let first_line = raw.trim().lines().next().unwrap_or("").trim();
if let Some((prefix, rest)) = first_line.split_once(": ") {
let class_like = prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception");
if class_like && prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') {
let class_like =
prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception");
if class_like
&& prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
{
let ty = prefix.rsplit('.').next().map(str::to_string);
return (ty, strip_html(rest));
}
}
if !first_line.is_empty()
&& first_line.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
&& first_line
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
&& first_line.contains('.')
{
// `frappe.exceptions.DoesNotExistError` with no message.
return (first_line.rsplit('.').next().map(str::to_string), String::new());
return (
first_line.rsplit('.').next().map(str::to_string),
String::new(),
);
}
(None, strip_html(first_line))
}
@@ -297,8 +362,16 @@ pub fn strip_html(input: &str) -> String {
}
'>' if in_tag => {
in_tag = false;
let name = tag.trim_start_matches('/').split_whitespace().next().unwrap_or("").to_ascii_lowercase();
if matches!(name.as_str(), "br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3") {
let name = tag
.trim_start_matches('/')
.split_whitespace()
.next()
.unwrap_or("")
.to_ascii_lowercase();
if matches!(
name.as_str(),
"br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3"
) {
out.push('\n');
}
}
@@ -339,7 +412,10 @@ mod tests {
})
.to_string();
let parsed = parse_error_body(&body);
assert_eq!(parsed.message.as_deref(), Some("Row 1: Account Sales & more; Second\nproblem"));
assert_eq!(
parsed.message.as_deref(),
Some("Row 1: Account Sales & more; Second\nproblem")
);
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
let err = ErpError::from_response(417, &body);
assert_eq!(err.kind, ErrorKind::Validation);
@@ -354,7 +430,10 @@ mod tests {
let b = parse_error_body(
&json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(),
);
assert_eq!(b.message.as_deref(), Some("Sales Invoice INV/1 already exists"));
assert_eq!(
b.message.as_deref(),
Some("Sales Invoice INV/1 already exists")
);
assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError"));
let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string());
@@ -363,8 +442,13 @@ mod tests {
#[test]
fn exception_prefix_is_only_dropped_when_it_looks_like_a_class_path() {
let parsed = parse_error_body(&json!({ "exception": "Something: with a colon but no class" }).to_string());
assert_eq!(parsed.message.as_deref(), Some("Something: with a colon but no class"));
let parsed = parse_error_body(
&json!({ "exception": "Something: with a colon but no class" }).to_string(),
);
assert_eq!(
parsed.message.as_deref(),
Some("Something: with a colon but no class")
);
assert_eq!(parsed.exc_type, None);
}
@@ -378,7 +462,10 @@ mod tests {
})
.to_string();
let parsed = parse_error_body(&body);
assert_eq!(parsed.message.as_deref(), Some("Customer X is disabled; Row 2: rate missing"));
assert_eq!(
parsed.message.as_deref(),
Some("Customer X is disabled; Row 2: rate missing")
);
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
}
@@ -406,22 +493,36 @@ mod tests {
(502, ErrorKind::Server),
(418, ErrorKind::Other),
] {
assert_eq!(ErpError::from_response(status, "").kind, kind, "status {status}");
assert_eq!(
ErpError::from_response(status, "").kind,
kind,
"status {status}"
);
}
// The exception class wins when the status is generic.
let dup = ErpError::from_response(417, &json!({ "exc_type": "DuplicateEntryError" }).to_string());
let dup = ErpError::from_response(
417,
&json!({ "exc_type": "DuplicateEntryError" }).to_string(),
);
assert_eq!(dup.kind, ErrorKind::Duplicate);
}
#[test]
fn secrets_are_redacted_from_messages() {
let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header").redacted(&["topsecret", "abc123"]);
let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header")
.redacted(&["topsecret", "abc123"]);
assert_eq!(err.message, "bad token [redacted]:[redacted] in header");
}
#[test]
fn display_carries_the_status_and_hint() {
let err = ErpError::from_response(401, &json!({ "message": "Invalid credentials" }).to_string());
assert_eq!(err.to_string(), "Authentication failed (HTTP 401): Invalid credentials");
let err = ErpError::from_response(
401,
&json!({ "message": "Invalid credentials" }).to_string(),
);
assert_eq!(
err.to_string(),
"Authentication failed (HTTP 401): Invalid credentials"
);
}
}
+460 -88
View File
@@ -11,8 +11,8 @@
use super::client::{ClientOptions, ErpClient};
use super::config::{self, ErpnextConfig, NamingMode, Secret};
use super::errors::ErrorKind;
use super::discovery::{test_connection, ConnectionTest, LocalFacts};
use super::errors::ErrorKind;
use super::push::{push_invoice, push_payment, PushResult};
use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl};
use crate::commands::payments::{record_payment_impl, PaymentInput};
@@ -78,7 +78,14 @@ impl Live {
..Default::default()
};
edit(&mut cfg);
let http = ErpClient::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, "", ClientOptions::default()).unwrap();
let http = ErpClient::new(
&cfg.base_url,
&cfg.api_key,
&cfg.api_secret,
"",
ClientOptions::default(),
)
.unwrap();
let mut conn = crate::db::open_in_memory().unwrap();
conn.execute(
@@ -88,8 +95,24 @@ impl Live {
)
.unwrap();
for (name, gstin, state, line1, city, pin, category) in [
("Voiced Live Intra Co", "", "27", "5 FC Road", "Pune", "411004", "unregistered"),
("Voiced Live Inter Co", KA_GSTIN, "29", "12 MG Road", "Bengaluru", "560001", "registered_regular"),
(
"Voiced Live Intra Co",
"",
"27",
"5 FC Road",
"Pune",
"411004",
"unregistered",
),
(
"Voiced Live Inter Co",
KA_GSTIN,
"29",
"12 MG Road",
"Bengaluru",
"560001",
"registered_regular",
),
] {
conn.execute(
"INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at)
@@ -98,12 +121,26 @@ impl Live {
)
.unwrap();
}
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() as u64;
let prefix = format!("L{}{}/", base36(nanos / 1_000_000, 4), base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1));
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
let prefix = format!(
"L{}{}/",
base36(nanos / 1_000_000, 4),
base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1)
);
start_new_series_impl(&mut conn, &prefix, 3).unwrap();
config::save(&conn, &cfg).unwrap();
let live = Live { db: Mutex::new(conn), dir: tempfile::tempdir().unwrap(), http, abbr, ic, prefix };
let live = Live {
db: Mutex::new(conn),
dir: tempfile::tempdir().unwrap(),
http,
abbr,
ic,
prefix,
};
// The same step the UI performs before the first push.
let ct = live.connection_test(&cfg).await;
let mut cfg = cfg;
@@ -113,8 +150,13 @@ impl Live {
}
async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest {
let local = LocalFacts { vendor_registered: true, ..Default::default() };
test_connection(&self.http, cfg, &local).await.expect("connection test")
let local = LocalFacts {
vendor_registered: true,
..Default::default()
};
test_connection(&self.http, cfg, &local)
.await
.expect("connection test")
}
fn cfg(&self) -> ErpnextConfig {
@@ -176,7 +218,10 @@ impl Live {
self.db
.lock()
.unwrap()
.execute("UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", params![sha, inv.id])
.execute(
"UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2",
params![sha, inv.id],
)
.unwrap();
}
@@ -185,13 +230,22 @@ impl Live {
}
async fn doc(&self, doctype: &str, name: &str) -> Value {
let v = self.http.get(&["api", "resource", doctype, name], &[]).await.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}"));
let v = self
.http
.get(&["api", "resource", doctype, name], &[])
.await
.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}"));
v["data"].clone()
}
async fn invoices_named(&self, number: &str) -> Vec<Value> {
self.http
.list_resource("Sales Invoice", &["name", "docstatus", "grand_total", "remarks"], json!([["remarks", "like", format!("%{number}%")]]), "creation asc")
.list_resource(
"Sales Invoice",
&["name", "docstatus", "grand_total", "remarks"],
json!([["remarks", "like", format!("%{number}%")]]),
"creation asc",
)
.await
.unwrap()
}
@@ -201,7 +255,10 @@ impl Live {
.list_resource(
"File",
&["name", "file_name", "is_private", "file_url"],
json!([["attached_to_doctype", "=", doctype], ["attached_to_name", "=", name]]),
json!([
["attached_to_doctype", "=", doctype],
["attached_to_name", "=", name]
]),
"creation asc",
)
.await
@@ -248,7 +305,13 @@ fn minimal_pdf(label: &str) -> Vec<u8> {
for o in offsets {
out.extend(format!("{o:010} 00000 n \n").into_bytes());
}
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes());
out.extend(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
objects.len() + 1
)
.into_bytes(),
);
out
}
@@ -263,8 +326,17 @@ fn assert_pushed(r: &PushResult) {
/// The invoice as ERPNext holds it: total equals Voiced's, tax rows are what Voiced computed.
async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
assert_eq!(paise(&doc["grand_total"]), crate::gst::rupees_to_paise(inv.total), "grand_total of {}", inv.number);
assert_eq!(doc["rounded_total"].as_f64().unwrap_or(0.0), 0.0, "rounding is disabled");
assert_eq!(
paise(&doc["grand_total"]),
crate::gst::rupees_to_paise(inv.total),
"grand_total of {}",
inv.number
);
assert_eq!(
doc["rounded_total"].as_f64().unwrap_or(0.0),
0.0,
"rounding is disabled"
);
assert_eq!(doc["posting_date"], inv.invoice_date.as_str());
let taxes = doc["taxes"].as_array().unwrap();
let by_head = |needle: &str| -> Option<i64> {
@@ -273,10 +345,25 @@ async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
.find(|t| t["account_head"].as_str().unwrap_or("").contains(needle))
.map(|t| paise(&t["tax_amount"]))
};
assert_eq!(by_head("CGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.cgst_amount).max(0), "CGST of {}", inv.number);
assert_eq!(
by_head("CGST").unwrap_or(0),
crate::gst::rupees_to_paise(inv.cgst_amount).max(0),
"CGST of {}",
inv.number
);
// IGST rows contain "IGST"; "CGST" and "SGST" rows do not.
assert_eq!(by_head("SGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.sgst_amount), "SGST of {}", inv.number);
assert_eq!(by_head("IGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.igst_amount), "IGST of {}", inv.number);
assert_eq!(
by_head("SGST").unwrap_or(0),
crate::gst::rupees_to_paise(inv.sgst_amount),
"SGST of {}",
inv.number
);
assert_eq!(
by_head("IGST").unwrap_or(0),
crate::gst::rupees_to_paise(inv.igst_amount),
"IGST of {}",
inv.number
);
let _ = live;
}
@@ -290,7 +377,10 @@ async fn live_connection_and_options() {
let ct = live.connection_test(&cfg).await;
println!("{}", serde_json::to_string_pretty(&ct).unwrap());
assert!(ct.user.contains('@'));
assert_eq!(ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")), Some(true));
assert_eq!(
ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")),
Some(true)
);
assert!(ct.versions.erpnext.is_some());
assert_eq!(ct.india_compliance, live.ic);
assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming");
@@ -298,14 +388,25 @@ async fn live_connection_and_options() {
let o = live.http.load_options(&cfg.company).await.unwrap();
println!("option errors: {:?}", o.errors);
assert!(o.errors.is_empty(), "{:?}", o.errors);
let has = |list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
let has =
|list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
assert!(has(&o.companies, &cfg.company));
assert!(has(&o.income_accounts, &cfg.income_account), "{:?}", o.income_accounts);
assert!(o.tax_accounts.iter().any(|a| a.name.contains("CGST")), "{:?}", o.tax_accounts);
assert!(
has(&o.income_accounts, &cfg.income_account),
"{:?}",
o.income_accounts
);
assert!(
o.tax_accounts.iter().any(|a| a.name.contains("CGST")),
"{:?}",
o.tax_accounts
);
assert!(has(&o.uoms, "Hour") && has(&o.uoms, "Nos"));
assert!(!o.company_addresses.is_empty());
assert!(!o.naming_series.is_empty());
assert!(!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty());
assert!(
!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty()
);
}
// ---- Sales Invoice mapping ----
@@ -361,10 +462,16 @@ async fn live_push_matrix() {
for (label, inv) in &cases {
live.archive(inv);
let r = live.push(inv, None).await;
println!("{label}: {} -> {} (warnings {:?})", inv.number, r.remote_name, r.warnings);
println!(
"{label}: {} -> {} (warnings {:?})",
inv.number, r.remote_name, r.warnings
);
assert_pushed(&r);
assert!(r.created && r.attached, "{label}: {r:?}");
assert_eq!(r.remote_name, inv.number, "mirror naming keeps the Voiced number");
assert_eq!(
r.remote_name, inv.number,
"mirror naming keeps the Voiced number"
);
let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert_eq!(doc["docstatus"], 0);
assert_matches(&live, inv, &doc).await;
@@ -376,11 +483,18 @@ async fn live_push_matrix() {
// Customer and address shape, as ERPNext stored them.
let inter = &cases[1].1;
let doc = live.doc("Sales Invoice", &inter.number).await;
let customer = live.doc("Customer", doc["customer"].as_str().unwrap()).await;
let customer = live
.doc("Customer", doc["customer"].as_str().unwrap())
.await;
assert_eq!(customer["customer_name"], "Voiced Live Inter Co");
assert_eq!(customer["customer_type"], "Company");
let address = live.doc("Address", doc["customer_address"].as_str().unwrap()).await;
println!("address: {}", json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]}));
let address = live
.doc("Address", doc["customer_address"].as_str().unwrap())
.await;
println!(
"address: {}",
json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]})
);
assert_eq!(address["state"], "Karnataka");
assert_eq!(address["country"], "India");
assert_eq!(address["address_type"], "Billing");
@@ -393,10 +507,17 @@ async fn live_push_matrix() {
}
// A fresh local database (lost link) reuses the customer's address instead of creating another.
let again = Live::new(|_| {}).await;
let inv = again.issue(false, json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
let inv = again.issue(
false,
json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&again.push(&inv, None).await);
let d2 = again.doc("Sales Invoice", &inv.number).await;
assert_eq!(d2["customer_address"], doc["customer_address"], "address reused");
assert_eq!(
d2["customer_address"], doc["customer_address"],
"address reused"
);
assert_eq!(d2["customer"], doc["customer"]);
}
@@ -408,7 +529,13 @@ async fn live_half_paise_rounding() {
// (taxable amount, discount): 9% of 10.50 is 0.945, 18% of 0.25 is 0.045 and so on. Voiced rounds half up.
// Half-even and half-up agree when the digit before the tie is odd (3.50 -> 0.315 -> 0.32), so only the
// first two are certain to differ under Banker's Rounding.
let ties = [(10.5, 0.0, true), (7310.5, 0.0, true), (3.5, 0.0, false), (0.25, 0.0, false), (99.99, 0.15, false)];
let ties = [
(10.5, 0.0, true),
(7310.5, 0.0, true),
(3.5, 0.0, false),
(0.25, 0.0, false),
(99.99, 0.15, false),
];
for (amount, discount, tie) in ties {
let inv = live.issue(
true,
@@ -416,16 +543,31 @@ async fn live_half_paise_rounding() {
json!({"discount": discount}),
);
let r = live.push(&inv, Some(true)).await;
println!("{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}", r.ok, r.error);
println!(
"{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}",
r.ok, r.error
);
if tie && banker {
// ERPNext's Banker's Rounding disagrees by a paise: the draft is reported, kept, and never submitted.
assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}");
assert!(
!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict),
"{r:?}"
);
let msg = r.error.as_deref().unwrap();
assert!(msg.contains("Commercial Rounding") && msg.contains("does not submit"), "{msg}");
assert!(
msg.contains("Commercial Rounding") && msg.contains("does not submit"),
"{msg}"
);
assert_eq!(r.remote_name, inv.number);
let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert_eq!(doc["docstatus"], 0, "a mismatching document is never submitted");
assert!(live.files_of("Sales Invoice", &r.remote_name).await.is_empty());
assert_eq!(
doc["docstatus"], 0,
"a mismatching document is never submitted"
);
assert!(live
.files_of("Sales Invoice", &r.remote_name)
.await
.is_empty());
// Pushing again finds the same draft and reports the same conflict instead of creating another.
let again = live.push(&inv, Some(true)).await;
assert!(!again.ok && again.status == "conflict", "{again:?}");
@@ -442,7 +584,11 @@ async fn live_half_paise_rounding() {
#[ignore = "needs the live ERPNext stack"]
async fn live_repush_is_idempotent() {
let live = Live::new(|_| {}).await;
let inv = live.issue(true, json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]), json!({}));
let inv = live.issue(
true,
json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]),
json!({}),
);
live.archive(&inv);
let first = live.push(&inv, None).await;
assert_pushed(&first);
@@ -452,7 +598,11 @@ async fn live_repush_is_idempotent() {
assert_pushed(&again);
assert!(again.no_op && !again.created, "{again:?}");
// Lose the local sync state: the POST gets a 409 and the document is adopted, not duplicated.
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
live.db
.lock()
.unwrap()
.execute("DELETE FROM erpnext_sync", [])
.unwrap();
let adopted = live.push(&inv, None).await;
assert_pushed(&adopted);
assert!(adopted.attached, "{adopted:?}");
@@ -460,7 +610,12 @@ async fn live_repush_is_idempotent() {
assert_eq!(adopted.remote_name, first.remote_name);
let all = live
.http
.list_resource("Sales Invoice", &["name"], json!([["name", "=", inv.number.clone()]]), "name asc")
.list_resource(
"Sales Invoice",
&["name"],
json!([["name", "=", inv.number.clone()]]),
"name asc",
)
.await
.unwrap();
assert_eq!(all.len(), 1);
@@ -475,13 +630,24 @@ async fn live_repush_is_idempotent() {
async fn live_submit_paths() {
let live = Live::new(|_| {}).await;
// Submit while creating.
let a = live.issue(true, json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
let a = live.issue(
true,
json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]),
json!({}),
);
let r = live.push(&a, Some(true)).await;
assert_pushed(&r);
assert_eq!(r.remote_docstatus, 1);
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
assert_eq!(
live.doc("Sales Invoice", &r.remote_name).await["docstatus"],
1
);
// Submit an existing draft in a later push (mirror: v2 method route).
let b = live.issue(true, json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]), json!({}));
let b = live.issue(
true,
json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&live.push(&b, Some(false)).await);
let r = live.push(&b, Some(true)).await;
assert_pushed(&r);
@@ -494,20 +660,43 @@ async fn live_submit_paths() {
async fn live_series_naming_mode() {
let live = Live::new(|c| c.naming_mode = NamingMode::Series).await;
let o = live.http.load_options(&live.cfg().company).await.unwrap();
let series = o.naming_series.first().expect("a naming series").name.clone();
let series = o
.naming_series
.first()
.expect("a naming series")
.name
.clone();
println!("series options: {:?}", o.naming_series);
live.save_cfg(|c| c.naming_series = series.clone());
let inv = live.issue(true, json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]), json!({"notes": "note"}));
let inv = live.issue(
true,
json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]),
json!({"notes": "note"}),
);
live.archive(&inv);
let r = live.push(&inv, Some(false)).await;
assert_pushed(&r);
assert!(r.created);
assert_ne!(r.remote_name, inv.number, "series mode lets ERPNext name it");
assert_ne!(
r.remote_name, inv.number,
"series mode lets ERPNext name it"
);
let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert!(doc["remarks"].as_str().unwrap().starts_with(&format!("Voiced invoice {}", inv.number)), "{}", doc["remarks"]);
assert!(
doc["remarks"]
.as_str()
.unwrap()
.starts_with(&format!("Voiced invoice {}", inv.number)),
"{}",
doc["remarks"]
);
assert_matches(&live, &inv, &doc).await;
// Lose the sync row: the remarks lookup finds the document instead of creating another.
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
live.db
.lock()
.unwrap()
.execute("DELETE FROM erpnext_sync", [])
.unwrap();
let again = live.push(&inv, Some(false)).await;
assert_pushed(&again);
assert!(!again.created, "{again:?}");
@@ -517,7 +706,10 @@ async fn live_series_naming_mode() {
let s = live.push(&inv, Some(true)).await;
assert_pushed(&s);
assert_eq!(s.remote_docstatus, 1);
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
assert_eq!(
live.doc("Sales Invoice", &r.remote_name).await["docstatus"],
1
);
}
#[tokio::test]
@@ -529,18 +721,62 @@ async fn live_uom_and_quantities() {
let row = |desc: &str, unit: &str, qty: f64| json!([{"description": desc, "mode": "rate", "rate": 100.0, "unit": unit, "quantity": qty, "hsnSac": "998314"}]);
let mut outcomes = Vec::new();
for (label, desc, unit, qty) in [
("Hour row on a Nos item, whole qty", "Nos preset", "hour", 2.0),
("Hour row on a Nos item, fractional qty", "Nos preset", "hour", 1.5),
("Hour row on an Hour item, fractional qty", "Hourly preset", "hour", 1.5),
("Nos row on an Hour item, whole qty", "Hourly preset", "unit", 3.0),
("code-less hours, fractional qty", "Code-less hours", "hour", 1.5),
("code-less minutes, fractional qty", "Code-less minutes", "minute", 90.5),
("code-less Nos, fractional qty", "Code-less nos", "unit", 1.5),
("code-less Nos, whole qty", "Code-less nos whole", "unit", 4.0),
(
"Hour row on a Nos item, whole qty",
"Nos preset",
"hour",
2.0,
),
(
"Hour row on a Nos item, fractional qty",
"Nos preset",
"hour",
1.5,
),
(
"Hour row on an Hour item, fractional qty",
"Hourly preset",
"hour",
1.5,
),
(
"Nos row on an Hour item, whole qty",
"Hourly preset",
"unit",
3.0,
),
(
"code-less hours, fractional qty",
"Code-less hours",
"hour",
1.5,
),
(
"code-less minutes, fractional qty",
"Code-less minutes",
"minute",
90.5,
),
(
"code-less Nos, fractional qty",
"Code-less nos",
"unit",
1.5,
),
(
"code-less Nos, whole qty",
"Code-less nos whole",
"unit",
4.0,
),
] {
let inv = live.issue(true, row(desc, unit, qty), json!({}));
let r = live.push(&inv, None).await;
println!("{label}: ok={} {}", r.ok, r.error.clone().unwrap_or_default());
println!(
"{label}: ok={} {}",
r.ok,
r.error.clone().unwrap_or_default()
);
outcomes.push((label, r.ok));
}
println!("{outcomes:?}");
@@ -548,7 +784,10 @@ async fn live_uom_and_quantities() {
assert!(ok("Hour row on an Hour item, fractional qty"));
assert!(ok("code-less hours, fractional qty") && ok("code-less minutes, fractional qty"));
assert!(ok("code-less Nos, whole qty"));
assert!(!ok("code-less Nos, fractional qty"), "ERPNext refuses a fraction of a whole-number UOM");
assert!(
!ok("code-less Nos, fractional qty"),
"ERPNext refuses a fraction of a whole-number UOM"
);
}
// ---- payments ----
@@ -569,8 +808,32 @@ async fn live_payment_entry() {
assert!(r.ok, "{:?}", r.error);
let entry = r.entry_name.clone().unwrap();
let pe = live.doc("Payment Entry", &entry).await;
let keys = ["payment_type", "party_type", "paid_from", "paid_to", "paid_amount", "received_amount", "base_paid_amount", "base_received_amount", "total_allocated_amount", "unallocated_amount", "difference_amount", "reference_no", "reference_date", "posting_date", "bank_account", "mode_of_payment", "status"];
println!("PE {}", json!(keys.iter().map(|k| (k.to_string(), pe[*k].clone())).collect::<serde_json::Map<_, _>>()));
let keys = [
"payment_type",
"party_type",
"paid_from",
"paid_to",
"paid_amount",
"received_amount",
"base_paid_amount",
"base_received_amount",
"total_allocated_amount",
"unallocated_amount",
"difference_amount",
"reference_no",
"reference_date",
"posting_date",
"bank_account",
"mode_of_payment",
"status",
];
println!(
"PE {}",
json!(keys
.iter()
.map(|k| (k.to_string(), pe[*k].clone()))
.collect::<serde_json::Map<_, _>>())
);
println!("PE references {}", pe["references"].as_array().unwrap().iter().map(|r| json!({"ref": r["reference_name"], "allocated": r["allocated_amount"], "total": r["total_amount"], "outstanding": r["outstanding_amount"]}).to_string()).collect::<Vec<_>>().join(","));
println!("PE deductions {}", pe["deductions"].as_array().unwrap().iter().map(|r| json!({"account": r["account"], "cost_center": r["cost_center"], "amount": r["amount"], "description": r["description"]}).to_string()).collect::<Vec<_>>().join(","));
assert_eq!(paise(&pe["paid_amount"]), cash);
@@ -580,14 +843,29 @@ async fn live_payment_entry() {
let ded = pe["deductions"].as_array().unwrap();
assert_eq!(ded.len(), 1);
assert_eq!(ded[0]["account"], live.cfg().tds_account.as_str());
assert_eq!(paise(&ded[0]["amount"]), tds, "a TDS deduction row is a positive amount");
assert_eq!(
paise(&ded[0]["amount"]),
tds,
"a TDS deduction row is a positive amount"
);
assert_eq!(pe["reference_no"], "UTR-LIVE-1");
assert_eq!(pe["posting_date"], "2026-06-20");
assert_eq!(pe["paid_to"], live.cfg().payment_bank_account.as_str(), "money lands in the configured bank account");
assert_eq!(
pe["paid_to"],
live.cfg().payment_bank_account.as_str(),
"money lands in the configured bank account"
);
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
println!("outstanding after payment: {}", inv_doc["outstanding_amount"]);
println!(
"outstanding after payment: {}",
inv_doc["outstanding_amount"]
);
assert_eq!(pe["docstatus"], 1);
assert_eq!(paise(&inv_doc["outstanding_amount"]), 0, "cash plus TDS settles the invoice");
assert_eq!(
paise(&inv_doc["outstanding_amount"]),
0,
"cash plus TDS settles the invoice"
);
// Idempotent locally.
let again = push_payment(&live.db, &live.http, pid).await;
assert!(again.ok && again.already_synced);
@@ -597,14 +875,22 @@ async fn live_payment_entry() {
#[ignore = "needs the live ERPNext stack"]
async fn live_payment_without_tds() {
let live = Live::new(|_| {}).await;
let inv = live.issue(true, json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
let inv = live.issue(
true,
json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&live.push(&inv, Some(true)).await);
let total = crate::gst::rupees_to_paise(inv.total);
let pid = live.payment(&inv, total / 2, 0);
let r = push_payment(&live.db, &live.http, pid).await;
assert!(r.ok, "{:?}", r.error);
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
assert_eq!(paise(&inv_doc["outstanding_amount"]), total - total / 2, "partial payment leaves the rest outstanding");
assert_eq!(
paise(&inv_doc["outstanding_amount"]),
total - total / 2,
"partial payment leaves the rest outstanding"
);
}
// ---- India Compliance only (ERPNEXT_IC=1) ----
@@ -625,10 +911,18 @@ async fn live_ic_hsn_enforcement() {
}
let live = Live::new(|_| {}).await;
let mut outcomes = Vec::new();
for (label, hsn) in [("no HSN", ""), ("4-digit HSN", "9983"), ("6-digit HSN", "998314"), ("8-digit HSN", "99831400")] {
for (label, hsn) in [
("no HSN", ""),
("4-digit HSN", "9983"),
("6-digit HSN", "998314"),
("8-digit HSN", "99831400"),
] {
let inv = live.issue(true, json!([{"description": format!("HSN {label}"), "mode": "fixed", "amount": 1000.0, "hsnSac": hsn}]), json!({}));
let r = live.push(&inv, Some(true)).await;
println!("{label}: ok={} docstatus={} err={:?}", r.ok, r.remote_docstatus, r.error);
println!(
"{label}: ok={} docstatus={} err={:?}",
r.ok, r.remote_docstatus, r.error
);
outcomes.push((label, r));
}
let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
@@ -653,7 +947,14 @@ async fn live_ic_number_longer_than_16_characters() {
// Voiced never issues such a number; this is a number imported or migrated from elsewhere.
let long = format!("{}-EXTRA-LONG-PART", inv.number);
assert!(long.len() > 16);
live.db.lock().unwrap().execute("UPDATE invoices SET number = ?1 WHERE id = ?2", params![long, inv.id]).unwrap();
live.db
.lock()
.unwrap()
.execute(
"UPDATE invoices SET number = ?1 WHERE id = ?2",
params![long, inv.id],
)
.unwrap();
let r = live.push(&inv, None).await;
println!("long number: ok={} err={:?}", r.ok, r.error);
assert!(!r.ok);
@@ -668,26 +969,55 @@ async fn live_ic_gst_account_warnings() {
return;
}
let live = Live::new(|_| {}).await;
let local = LocalFacts { vendor_registered: true, ..Default::default() };
let codes = |ct: &ConnectionTest| ct.warnings.iter().map(|w| w.code.clone()).collect::<Vec<_>>();
let local = LocalFacts {
vendor_registered: true,
..Default::default()
};
let codes = |ct: &ConnectionTest| {
ct.warnings
.iter()
.map(|w| w.code.clone())
.collect::<Vec<_>>()
};
let clean = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
let clean = test_connection(&live.http, &live.cfg(), &local)
.await
.unwrap();
assert!(clean.warnings.is_empty(), "{:?}", clean.warnings);
let mut cfg = live.cfg();
cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr);
let wrong = test_connection(&live.http, &cfg, &local).await.unwrap();
assert_eq!(codes(&wrong), ["gst_account_mismatch"], "{:?}", wrong.warnings);
assert_eq!(
codes(&wrong),
["gst_account_mismatch"],
"{:?}",
wrong.warnings
);
let mut cfg = live.cfg();
cfg.company = "No Such Co".into();
let other = test_connection(&live.http, &cfg, &local).await.unwrap();
assert!(codes(&other).contains(&"gst_accounts_not_configured".to_string()), "{:?}", other.warnings);
assert!(
codes(&other).contains(&"gst_accounts_not_configured".to_string()),
"{:?}",
other.warnings
);
// Mirror mode with a number the site would refuse.
let local = LocalFacts { vendor_registered: true, next_number: Some("TOOLONG-INVOICE-0001".into()), ..Default::default() };
let ct = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
assert!(codes(&ct).contains(&"number_invalid".to_string()), "{:?}", ct.warnings);
let local = LocalFacts {
vendor_registered: true,
next_number: Some("TOOLONG-INVOICE-0001".into()),
..Default::default()
};
let ct = test_connection(&live.http, &live.cfg(), &local)
.await
.unwrap();
assert!(
codes(&ct).contains(&"number_invalid".to_string()),
"{:?}",
ct.warnings
);
}
#[tokio::test]
@@ -702,7 +1032,11 @@ async fn live_ic_reverse_charge_and_gst_fields() {
// India Compliance refuses is_reverse_charge=1 without negative RCM tax rows ("Booked reverse charge is not
// equal to applied tax amount"), whatever GST Settings say, so it is sent as 0 with a warning.
assert_pushed(&r);
assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings);
assert!(
r.warnings.iter().any(|w| w.contains("reverse charge")),
"{:?}",
r.warnings
);
let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert_eq!(doc["is_reverse_charge"], 0);
assert_matches(&live, &inv, &doc).await;
@@ -718,13 +1052,34 @@ async fn live_ic_state_names_match_voiced() {
return;
}
let live = Live::new(|_| {}).await;
let v = live.http.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Address".to_string())]).await.unwrap();
let v = live
.http
.get(
&["api", "method", "frappe.desk.form.load.getdoctype"],
&[("doctype", "Address".to_string())],
)
.await
.unwrap();
let docs = v["docs"].as_array().unwrap();
let address = docs.iter().find(|d| d["name"] == "Address").unwrap();
let options = address["fields"].as_array().unwrap().iter().find(|f| f["fieldname"] == "gst_state").expect("IC adds gst_state");
let ic: Vec<&str> = options["options"].as_str().unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).collect();
let options = address["fields"]
.as_array()
.unwrap()
.iter()
.find(|f| f["fieldname"] == "gst_state")
.expect("IC adds gst_state");
let ic: Vec<&str> = options["options"]
.as_str()
.unwrap()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
for (code, name) in crate::gst::STATES {
assert!(ic.contains(name), "India Compliance has no state named {name:?} ({code}); it has {ic:?}");
assert!(
ic.contains(name),
"India Compliance has no state named {name:?} ({code}); it has {ic:?}"
);
}
assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}");
}
@@ -733,23 +1088,40 @@ async fn live_ic_state_names_match_voiced() {
#[ignore = "needs the live ERPNext stack"]
async fn live_payment_edge_cases() {
let live = Live::new(|_| {}).await;
let inv = live.issue(true, json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
let inv = live.issue(
true,
json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
json!({}),
);
// Not submitted yet: refused locally, nothing sent.
assert_pushed(&live.push(&inv, Some(false)).await);
let early = live.payment(&inv, 100_00, 0);
let r = push_payment(&live.db, &live.http, early).await;
assert!(!r.ok && r.error.as_deref().unwrap().contains("not submitted"), "{r:?}");
assert!(
!r.ok && r.error.as_deref().unwrap().contains("not submitted"),
"{r:?}"
);
assert_pushed(&live.push(&inv, Some(true)).await);
let total = crate::gst::rupees_to_paise(inv.total);
// TDS only (no cash): settled by deduction alone.
let tds_only = live.payment(&inv, 0, total - 100_00);
let r = push_payment(&live.db, &live.http, tds_only).await;
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS only"), "{r:?}");
assert!(
!r.ok && r.error.as_deref().unwrap().contains("TDS only"),
"{r:?}"
);
// Without a configured TDS account a TDS payment is refused locally.
live.save_cfg(|c| c.tds_account = String::new());
let inv2 = live.issue(true, json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
let inv2 = live.issue(
true,
json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&live.push(&inv2, Some(true)).await);
let t = live.payment(&inv2, 100_00, 50_00);
let r = push_payment(&live.db, &live.http, t).await;
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS account"), "{r:?}");
assert!(
!r.ok && r.error.as_deref().unwrap().contains("TDS account"),
"{r:?}"
);
}
+186 -42
View File
@@ -150,7 +150,12 @@ pub fn remarks_marker(number: &str) -> String {
fn verified_totals(inv: &Invoice) -> Result<(TaxType, i64, Totals), String> {
let tax_type = parse_tax_type(&inv.tax_type)?;
let rate_bp = (inv.tax_rate * 100.0).round() as i64;
let totals = gst::compute_totals(&inv.items, gst::rupees_to_paise(inv.discount), tax_type, rate_bp);
let totals = gst::compute_totals(
&inv.items,
gst::rupees_to_paise(inv.discount),
tax_type,
rate_bp,
);
let stored = [
(totals.cgst, inv.cgst_amount, "CGST"),
(totals.sgst, inv.sgst_amount, "SGST"),
@@ -199,13 +204,21 @@ fn distinct_descriptions(items: &[InvoiceItem]) -> Vec<String> {
.enumerate()
.map(|(i, item)| {
let trimmed = item.description.trim();
let full = if trimmed.is_empty() { format!("Item {}", i + 1) } else { trimmed.to_string() };
let full = if trimmed.is_empty() {
format!("Item {}", i + 1)
} else {
trimmed.to_string()
};
let (head, tail) = first_line_and_rest(&full);
let mut candidate = full.clone();
let mut n = 2;
while used.contains(&candidate.to_lowercase()) {
let head = format!("{head} ({n})");
candidate = if tail.is_empty() { head } else { format!("{head}\n{tail}") };
candidate = if tail.is_empty() {
head
} else {
format!("{head}\n{tail}")
};
n += 1;
}
used.insert(candidate.to_lowercase());
@@ -271,7 +284,10 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
));
}
let (head, _) = first_line_and_rest(&descriptions[i]);
row.insert("item_name".into(), json!(truncate_chars(head, ITEM_NAME_MAX)));
row.insert(
"item_name".into(),
json!(truncate_chars(head, ITEM_NAME_MAX)),
);
}
}
row.insert("description".into(), json!(descriptions[i]));
@@ -296,7 +312,9 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
if account.trim().is_empty() {
return Err(format!("The {head} account is not set in the ERPNext settings."));
return Err(format!(
"The {head} account is not set in the ERPNext settings."
));
}
Ok(json!({
"charge_type": "On Net Total",
@@ -306,10 +324,19 @@ fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
}))
}
fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state: &str) -> Result<Vec<Value>, String> {
fn tax_rows(
cfg: &ErpnextConfig,
tax_type: TaxType,
rate_bp: i64,
supplier_state: &str,
) -> Result<Vec<Value>, String> {
match tax_type {
TaxType::None => Ok(Vec::new()),
TaxType::Igst => Ok(vec![tax_row(&cfg.igst_account, "IGST", &scaled_decimal(rate_bp, 2))?]),
TaxType::Igst => Ok(vec![tax_row(
&cfg.igst_account,
"IGST",
&scaled_decimal(rate_bp, 2),
)?]),
TaxType::CgstSgst => {
// rate_bp is in hundredths of a percent; half of it in thousandths of a percent is rate_bp * 5.
let half = scaled_decimal(rate_bp * 5, 3);
@@ -319,7 +346,10 @@ fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state
} else {
&cfg.sgst_account
};
Ok(vec![tax_row(&cfg.cgst_account, "CGST", &half)?, tax_row(second_account, second, &half)?])
Ok(vec![
tax_row(&cfg.cgst_account, "CGST", &half)?,
tax_row(second_account, second, &half)?,
])
}
}
}
@@ -328,7 +358,10 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
let inv = ctx.invoice;
let cfg = ctx.config;
if inv.status != "issued" {
return Err(format!("Invoice {} is {}; only issued invoices are sent to ERPNext.", inv.number, inv.status));
return Err(format!(
"Invoice {} is {}; only issued invoices are sent to ERPNext.",
inv.number, inv.status
));
}
if ctx.customer.trim().is_empty() {
return Err("The ERPNext customer is not resolved.".into());
@@ -346,7 +379,9 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
}
NamingMode::Series => {
if cfg.naming_series.trim().is_empty() {
return Err("ERPNext series naming is selected but no naming series is set.".into());
return Err(
"ERPNext series naming is selected but no naming series is set.".into(),
);
}
body.insert("naming_series".into(), json!(cfg.naming_series.trim()));
}
@@ -396,14 +431,21 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
body.insert("discount_amount".into(), money(totals.discount));
}
body.insert("items".into(), Value::Array(item_rows(ctx, gst_fields)?));
body.insert("taxes".into(), Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?));
body.insert(
"taxes".into(),
Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?),
);
if ctx.submit {
body.insert("docstatus".into(), json!(1));
}
let mirror = cfg.naming_mode == NamingMode::Mirror;
Ok(BuiltRequest {
path: if mirror { SALES_INVOICE_V2 } else { SALES_INVOICE_V1 },
path: if mirror {
SALES_INVOICE_V2
} else {
SALES_INVOICE_V1
},
body: Value::Object(body),
idempotent: mirror,
})
@@ -416,7 +458,11 @@ fn has_gstin(client: &Client) -> bool {
!g.is_empty() && !g.eq_ignore_ascii_case("NA")
}
pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bool) -> Result<BuiltRequest, String> {
pub fn build_customer(
client: &Client,
cfg: &ErpnextConfig,
india_compliance: bool,
) -> Result<BuiltRequest, String> {
let name = client.name.trim();
if name.is_empty() {
return Err("The client has no name.".into());
@@ -427,19 +473,33 @@ pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bo
set_if(&mut body, "customer_group", &cfg.customer_group);
set_if(&mut body, "territory", &cfg.territory);
if india_compliance {
body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?));
let taxable_registration = matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez");
body.insert(
"gst_category".into(),
json!(gst_category_label(&client.gst_category)?),
);
let taxable_registration = matches!(
client.gst_category.as_str(),
"registered_regular" | "composition" | "sez"
);
if taxable_registration && has_gstin(client) {
let gstin = client.gstin.trim().to_ascii_uppercase();
gst::validate_gstin(&gstin).map_err(|e| format!("Client GSTIN: {e}"))?;
body.insert("gstin".into(), json!(gstin));
}
}
Ok(BuiltRequest { path: CUSTOMER, body: Value::Object(body), idempotent: false })
Ok(BuiltRequest {
path: CUSTOMER,
body: Value::Object(body),
idempotent: false,
})
}
/// `customer` is the name ERPNext returned when the Customer was created (a duplicate becomes "X - 1").
pub fn build_address(client: &Client, customer: &str, india_compliance: bool) -> Result<BuiltRequest, String> {
pub fn build_address(
client: &Client,
customer: &str,
india_compliance: bool,
) -> Result<BuiltRequest, String> {
if customer.trim().is_empty() {
return Err("The ERPNext customer is not resolved.".into());
}
@@ -472,16 +532,31 @@ pub fn build_address(client: &Client, customer: &str, india_compliance: bool) ->
set_if(&mut body, "pincode", &client.pincode);
body.insert("country".into(), json!("India"));
if india_compliance {
body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?));
if has_gstin(client) && matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez") {
body.insert("gstin".into(), json!(client.gstin.trim().to_ascii_uppercase()));
body.insert(
"gst_category".into(),
json!(gst_category_label(&client.gst_category)?),
);
if has_gstin(client)
&& matches!(
client.gst_category.as_str(),
"registered_regular" | "composition" | "sez"
)
{
body.insert(
"gstin".into(),
json!(client.gstin.trim().to_ascii_uppercase()),
);
}
}
body.insert(
"links".into(),
json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]),
);
Ok(BuiltRequest { path: ADDRESS, body: Value::Object(body), idempotent: false })
Ok(BuiltRequest {
path: ADDRESS,
body: Value::Object(body),
idempotent: false,
})
}
#[cfg(test)]
@@ -539,7 +614,13 @@ mod tests {
Vendor::from_snapshot(&inv.vendor_snapshot).unwrap()
}
fn build(inv: &Invoice, cfg: &ErpnextConfig, ic: bool, codes: &[Option<String>], submit: bool) -> Result<BuiltRequest, String> {
fn build(
inv: &Invoice,
cfg: &ErpnextConfig,
ic: bool,
codes: &[Option<String>],
submit: bool,
) -> Result<BuiltRequest, String> {
let v = vendor(inv);
build_sales_invoice(&InvoiceContext {
invoice: inv,
@@ -597,7 +678,10 @@ mod tests {
assert_eq!(scaled_decimal(1500, 3), "1.5");
assert_eq!(scaled_decimal(2625, 3), "2.625");
assert_eq!(money(731_005).to_string(), "7310.05");
assert_eq!(money(i64::from(u32::MAX) * 100 + 7).to_string(), "4294967295.07");
assert_eq!(
money(i64::from(u32::MAX) * 100 + 7).to_string(),
"4294967295.07"
);
}
#[test]
@@ -607,7 +691,10 @@ mod tests {
assert_eq!(place_of_supply_label("97").unwrap(), "97-Other Territory");
assert_eq!(place_of_supply_label("96").unwrap(), "96-Other Countries");
for (code, name) in gst::STATES {
assert_eq!(place_of_supply_label(code).unwrap(), format!("{code}-{name}"));
assert_eq!(
place_of_supply_label(code).unwrap(),
format!("{code}-{name}")
);
}
assert!(place_of_supply_label("28").is_err());
}
@@ -750,10 +837,16 @@ mod tests {
#[test]
fn plain_erpnext_registered_vendor_gets_tax_rows_but_no_india_compliance_fields() {
let mut conn = registered();
let inv = issue(&mut conn, json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }));
let inv = issue(
&mut conn,
json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }),
);
let req = build(&inv, &cfg(), false, &[None], false).unwrap();
assert_eq!(req.body["taxes"].as_array().unwrap().len(), 2);
assert!(req.body.get("place_of_supply").is_none() && req.body.get("is_reverse_charge").is_none());
assert!(
req.body.get("place_of_supply").is_none()
&& req.body.get("is_reverse_charge").is_none()
);
assert!(req.body["items"][0].get("gst_hsn_code").is_none());
}
@@ -795,8 +888,17 @@ mod tests {
let req = build(&inv, &cfg(), false, &codes, false).unwrap();
let rows = req.body["items"].as_array().unwrap();
assert!(rows[0].get("stock_uom").is_none());
assert_eq!((rows[0]["uom"].as_str(), rows[0]["conversion_factor"].as_i64()), (Some("Hour"), Some(1)));
assert_eq!((rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), (Some("Minute"), Some("Minute")));
assert_eq!(
(
rows[0]["uom"].as_str(),
rows[0]["conversion_factor"].as_i64()
),
(Some("Hour"), Some(1))
);
assert_eq!(
(rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()),
(Some("Minute"), Some("Minute"))
);
}
#[test]
@@ -849,7 +951,10 @@ mod tests {
assert!(!req.idempotent);
assert!(req.body.get("name").is_none());
assert_eq!(req.body["naming_series"], "SINV-.YY.-");
assert_eq!(req.body["remarks"], format!("Voiced invoice {}\nNet 30", inv.number));
assert_eq!(
req.body["remarks"],
format!("Voiced invoice {}\nNet 30", inv.number)
);
c.naming_series.clear();
assert!(build(&inv, &c, true, &[None], false).is_err());
@@ -859,8 +964,15 @@ mod tests {
fn submit_adds_docstatus_and_drafts_do_not() {
let mut conn = registered();
let inv = issue(&mut conn, json!({}));
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("docstatus").is_none());
assert_eq!(build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"], 1);
assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap()
.body
.get("docstatus")
.is_none());
assert_eq!(
build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"],
1
);
}
#[test]
@@ -874,7 +986,9 @@ mod tests {
// A supplier in a union territory without a legislature charges UTGST, not SGST.
let mut ut = inv.clone();
ut.vendor_snapshot = ut.vendor_snapshot.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\"");
ut.vendor_snapshot = ut
.vendor_snapshot
.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\"");
let req = build(&ut, &cfg(), true, &[None], false).unwrap();
assert_eq!(req.body["taxes"][1]["account_head"], "Output UTGST - AC");
assert_eq!(req.body["taxes"][1]["description"], "UTGST @ 2.625%");
@@ -901,11 +1015,17 @@ mod tests {
let mut inv = issue(&mut conn, json!({}));
let mut c = cfg();
c.cgst_account.clear();
assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("CGST account"));
assert!(build(&inv, &c, true, &[None], false)
.unwrap_err()
.contains("CGST account"));
c.company.clear();
assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("company"));
assert!(build(&inv, &c, true, &[None], false)
.unwrap_err()
.contains("company"));
inv.status = "cancelled".into();
assert!(build(&inv, &cfg(), true, &[None], false).unwrap_err().contains("only issued"));
assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap_err()
.contains("only issued"));
}
#[test]
@@ -913,9 +1033,17 @@ mod tests {
let mut conn = registered();
let mut inv = issue(&mut conn, json!({}));
inv.due_date = "2026-03-01".into();
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none());
assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap()
.body
.get("due_date")
.is_none());
inv.due_date.clear();
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none());
assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap()
.body
.get("due_date")
.is_none());
}
#[test]
@@ -960,11 +1088,17 @@ mod tests {
}
let mut unreg = client();
unreg.gst_category = "unregistered".into();
assert!(build_customer(&unreg, &cfg(), true).unwrap().body.get("gstin").is_none());
assert!(build_customer(&unreg, &cfg(), true)
.unwrap()
.body
.get("gstin")
.is_none());
let mut bad = client();
bad.gstin = "29AABCU9603R1Z0".into();
assert!(build_customer(&bad, &cfg(), true).unwrap_err().contains("GSTIN"));
assert!(build_customer(&bad, &cfg(), true)
.unwrap_err()
.contains("GSTIN"));
}
#[test]
@@ -989,7 +1123,10 @@ mod tests {
);
let mut jk = client();
jk.state_code = "01".into();
assert_eq!(build_address(&jk, "X", true).unwrap().body["state"], "Jammu and Kashmir");
assert_eq!(
build_address(&jk, "X", true).unwrap().body["state"],
"Jammu and Kashmir"
);
}
#[test]
@@ -997,7 +1134,10 @@ mod tests {
let mut c = client();
c.address_line1.clear();
c.address = "5 Park Street\nKolkata, West Bengal - 700016".into();
assert_eq!(build_address(&c, "X", false).unwrap().body["address_line1"], "5 Park Street");
assert_eq!(
build_address(&c, "X", false).unwrap().body["address_line1"],
"5 Park Street"
);
let mut no_city = client();
no_city.city.clear();
@@ -1005,7 +1145,11 @@ mod tests {
let mut no_state = client();
no_state.state_code.clear();
assert!(build_address(&no_state, "X", true).is_err());
assert!(build_address(&no_state, "X", false).unwrap().body.get("state").is_none());
assert!(build_address(&no_state, "X", false)
.unwrap()
.body
.get("state")
.is_none());
assert!(build_address(&client(), " ", true).is_err());
}
}
+314 -73
View File
@@ -9,8 +9,8 @@ use super::config::{self, ErpnextConfig, NamingMode};
use super::discovery::ic_number_ok;
use super::errors::{ErpError, ErrorKind};
use super::mapping::{
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, InvoiceContext, Vendor,
SALES_INVOICE_V2,
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker,
InvoiceContext, Vendor, SALES_INVOICE_V2,
};
use crate::commands::archive::read_archive_impl;
use crate::commands::invoice::get_invoice_impl;
@@ -31,14 +31,20 @@ use std::sync::Mutex;
type Db = Mutex<Connection>;
const DOCTYPE_INVOICE: &str = "Sales Invoice";
const GET_PAYMENT_ENTRY: &str = "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
const GET_PAYMENT_ENTRY: &str =
"erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
fn pre(message: impl Into<String>) -> ErpError {
ErpError::new(ErrorKind::Precondition, message)
}
fn with_db<T>(db: &Db, f: impl FnOnce(&mut Connection) -> Result<T, String>) -> Result<T, ErpError> {
let mut conn = db.lock().map_err(|e| pre(format!("The database is busy: {e}")))?;
fn with_db<T>(
db: &Db,
f: impl FnOnce(&mut Connection) -> Result<T, String>,
) -> Result<T, ErpError> {
let mut conn = db
.lock()
.map_err(|e| pre(format!("The database is busy: {e}")))?;
f(&mut conn).map_err(|e| pre(format!("Could not read or save the sync state: {e}")))
}
@@ -197,7 +203,11 @@ pub fn sync_statuses(conn: &Connection) -> Result<Vec<SyncStatus>, String> {
/// `<base>/app/sales-invoice/<name>`, with the name URL-encoded (a mirrored number contains a slash).
pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
const KEEP: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~');
const KEEP: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
let row = load_sync(conn, invoice_id)?;
let name = row.map(|r| r.remote_name).unwrap_or_default();
if name.is_empty() {
@@ -205,7 +215,10 @@ pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
}
let cfg = config::load(conn)?;
let base = super::client::normalize_base_url(&cfg.base_url).map_err(|e| e.to_string())?;
Ok(format!("{base}/app/sales-invoice/{}", utf8_percent_encode(&name, KEEP)))
Ok(format!(
"{base}/app/sales-invoice/{}",
utf8_percent_encode(&name, KEEP)
))
}
// ---- loading ----
@@ -294,7 +307,12 @@ fn load_client(conn: &Connection, inv: &Invoice) -> Result<ClientRow, String> {
address_line2: String::new(),
city: String::new(),
pincode: String::new(),
gst_category: if has_gstin { "registered_regular" } else { "unregistered" }.into(),
gst_category: if has_gstin {
"registered_regular"
} else {
"unregistered"
}
.into(),
default_notes: String::new(),
payment_terms_days: None,
invoice_count: 0,
@@ -321,7 +339,11 @@ fn load_item_codes(conn: &Connection, inv: &Invoice) -> Result<Vec<Option<String
// ORDER BY id DESC: the oldest preset wins on a repeated description.
by_desc.insert(desc.trim().to_lowercase(), code.trim().to_string());
}
Ok(inv.items.iter().map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned()).collect())
Ok(inv
.items
.iter()
.map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned())
.collect())
}
/// True when a connection test result is stored (blank, `null` and `{}` count as none).
@@ -353,8 +375,11 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
}
}
let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?;
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot)
.ok_or_else(|| fail(pre(format!("Invoice {number} has no supplier details recorded, so it cannot be sent."))))?;
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot).ok_or_else(|| {
fail(pre(format!(
"Invoice {number} has no supplier details recorded, so it cannot be sent."
)))
})?;
// A registered vendor's invoices go out with India Compliance fields, and those depend on what the
// connection test detected, so refuse until the test has run once.
if vendor.registered && !has_detect_result(&cfg.last_detect_result) {
@@ -382,7 +407,17 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
.ok()
.and_then(|v| v.get("indiaCompliance").and_then(Value::as_bool))
.unwrap_or(false);
Ok(Loaded { cfg, invoice, vendor, client, item_codes, sync, pdf, pdf_warning, india_compliance })
Ok(Loaded {
cfg,
invoice,
vendor,
client,
item_codes,
sync,
pdf,
pdf_warning,
india_compliance,
})
}
// ---- address checks (local, before anything is sent) ----
@@ -430,7 +465,10 @@ fn gstin_applies(client: &Client) -> bool {
let g = client.gstin.trim();
!g.is_empty()
&& !g.eq_ignore_ascii_case("NA")
&& matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez")
&& matches!(
client.gst_category.as_str(),
"registered_regular" | "composition" | "sez"
)
}
/// The checks India Compliance would make on an Address, done here so the error is readable and local.
@@ -440,7 +478,10 @@ pub fn validate_address(client: &Client) -> Result<(), String> {
let pin = client.pincode.trim();
if !pin.is_empty() {
if pin.len() != 6 || !pin.bytes().all(|b| b.is_ascii_digit()) || pin.starts_with('0') {
return Err(format!("{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.", client.name.trim()));
return Err(format!(
"{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.",
client.name.trim()
));
}
if let Some(allowed) = pin_prefixes(state_code) {
if !allowed.iter().any(|p| pin.starts_with(p)) {
@@ -482,7 +523,9 @@ fn doc_docstatus(doc: &Value) -> i64 {
fn doc_total_paise(doc: &Value) -> Option<i64> {
let v = doc.get("grand_total")?;
let n = v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?;
let n = v
.as_f64()
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?;
Some(gst::rupees_to_paise(n))
}
@@ -502,13 +545,26 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
"creation asc",
)
.await?;
found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string);
found = rows
.first()
.and_then(|r| r.get("name"))
.and_then(Value::as_str)
.map(str::to_string);
}
if found.is_none() {
let rows = http
.list_resource("Customer", &["name"], json!([["customer_name", "=", name]]), "creation asc")
.list_resource(
"Customer",
&["name"],
json!([["customer_name", "=", name]]),
"creation asc",
)
.await?;
found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string);
found = rows
.first()
.and_then(|r| r.get("name"))
.and_then(Value::as_str)
.map(str::to_string);
}
let customer = match found {
Some(c) => c,
@@ -521,14 +577,19 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
let req = build_customer(client, &l.cfg, l.india_compliance).map_err(pre)?;
let resp = http.post(req.path, &req.body, req.idempotent).await?;
// A duplicate name comes back as "X - 1": always use what the server returned.
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new customer's name."))?
doc_name(&resp).ok_or_else(|| {
ErpError::protocol("ERPNext did not return the new customer's name.")
})?
}
};
if let Some(id) = l.client.id {
with_db(db, |c| {
c.execute("UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2", params![customer, id])
.map(|_| ())
.map_err(|e| e.to_string())
c.execute(
"UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2",
params![customer, id],
)
.map(|_| ())
.map_err(|e| e.to_string())
})?;
}
Ok(customer)
@@ -548,16 +609,27 @@ async fn ensure_address(
let Some(client_id) = l.client.id else {
return Ok((
None,
Some("The client is not saved, so the invoice was sent without a customer address.".into()),
Some(
"The client is not saved, so the invoice was sent without a customer address."
.into(),
),
));
};
let has_address = [&client.address_line1, &client.address, &client.city, &client.pincode]
.iter()
.any(|s| !s.trim().is_empty());
let has_address = [
&client.address_line1,
&client.address,
&client.city,
&client.pincode,
]
.iter()
.any(|s| !s.trim().is_empty());
if !has_address {
return Ok((
None,
Some(format!("{} has no address saved, so the invoice was sent without a customer address.", client.name.trim())),
Some(format!(
"{} has no address saved, so the invoice was sent without a customer address.",
client.name.trim()
)),
));
}
validate_address(client).map_err(pre)?;
@@ -568,13 +640,18 @@ async fn ensure_address(
Some(existing) => existing,
None => {
let resp = http.post(req.path, &req.body, req.idempotent).await?;
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?
doc_name(&resp).ok_or_else(|| {
ErpError::protocol("ERPNext did not return the new address's name.")
})?
}
};
with_db(db, |c| {
c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id])
.map(|_| ())
.map_err(|e| e.to_string())
c.execute(
"UPDATE clients SET erpnext_address = ?1 WHERE id = ?2",
params![name, client_id],
)
.map(|_| ())
.map_err(|e| e.to_string())
})?;
Ok((Some(name), None))
}
@@ -593,7 +670,15 @@ async fn find_address(http: &ErpClient, customer: &str, body: &Value) -> Option<
if let Some(pin) = body.get("pincode").and_then(Value::as_str) {
filters.push(json!(["pincode", "=", pin]));
}
let rows = http.list_resource("Address", &["name"], Value::Array(filters), "`tabAddress`.creation asc").await.ok()?;
let rows = http
.list_resource(
"Address",
&["name"],
Value::Array(filters),
"`tabAddress`.creation asc",
)
.await
.ok()?;
rows.first()?.get("name")?.as_str().map(str::to_string)
}
@@ -656,7 +741,10 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result<Option<RemoteDo
.list_resource(
DOCTYPE_INVOICE,
&["name", "docstatus", "grand_total", "remarks"],
json!([["remarks", "like", format!("{marker}%")], ["docstatus", "!=", 2]]),
json!([
["remarks", "like", format!("{marker}%")],
["docstatus", "!=", 2]
]),
"creation asc",
)
.await?;
@@ -674,7 +762,11 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result<Option<RemoteDo
}
}
async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltRequest) -> Result<RemoteDoc, ErpError> {
async fn create_or_find(
http: &ErpClient,
l: &Loaded,
body: &mapping::BuiltRequest,
) -> Result<RemoteDoc, ErpError> {
let inv = &l.invoice;
if l.cfg.naming_mode == NamingMode::Series {
if let Some(found) = find_by_remarks(http, l).await? {
@@ -688,10 +780,17 @@ async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltReque
(Some(n), _) => n,
(None, NamingMode::Mirror) => inv.number.clone(),
(None, NamingMode::Series) => {
return Err(ErpError::protocol("ERPNext did not return the new Sales Invoice's name."))
return Err(ErpError::protocol(
"ERPNext did not return the new Sales Invoice's name.",
))
}
};
Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true, total_paise: doc_total_paise(&doc) })
Ok(RemoteDoc {
name,
docstatus: doc_docstatus(&doc),
created: true,
total_paise: doc_total_paise(&doc),
})
}
Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => {
// The mirrored name is taken: either a repeat of an earlier push or someone else's document.
@@ -719,11 +818,20 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res
http.post(&path, &json!({}), false).await?
}
NamingMode::Series => {
http.post(&["api", "resource", DOCTYPE_INVOICE, name], &json!({ "run_method": "submit" }), false).await?
http.post(
&["api", "resource", DOCTYPE_INVOICE, name],
&json!({ "run_method": "submit" }),
false,
)
.await?
}
};
// A 2xx without a docstatus is taken as submitted; one that says otherwise is not.
match resp.get("data").and_then(|d| d.get("docstatus")).and_then(Value::as_i64) {
match resp
.get("data")
.and_then(|d| d.get("docstatus"))
.and_then(Value::as_i64)
{
Some(1) | None => Ok(1),
Some(other) => Err(ErpError::protocol(format!(
"ERPNext accepted the submit request but the document is still at docstatus {other}."
@@ -734,15 +842,32 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res
fn attachment_file_name(number: &str) -> String {
let cleaned: String = number
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' })
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'-'
}
})
.collect();
let cleaned = cleaned.trim_matches('-');
format!("{}.pdf", if cleaned.is_empty() { "invoice" } else { cleaned })
format!(
"{}.pdf",
if cleaned.is_empty() {
"invoice"
} else {
cleaned
}
)
}
/// True when the document already carries a file of this name. Used when the local row lost its attachment
/// hash (a re-adopted document), so the same PDF is not attached twice.
async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) -> Result<bool, ErpError> {
async fn has_attachment(
http: &ErpClient,
remote_name: &str,
file_name: &str,
) -> Result<bool, ErpError> {
let rows = http
.list_resource(
"File",
@@ -759,9 +884,19 @@ async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) ->
}
/// `adopted`: the document was found, not created by this push, and nothing is recorded as attached.
async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf, adopted: bool) -> Result<(), ErpError> {
async fn attach_pdf(
http: &ErpClient,
l: &Loaded,
remote_name: &str,
pdf: &Pdf,
adopted: bool,
) -> Result<(), ErpError> {
let file_name = attachment_file_name(&l.invoice.number);
if adopted && has_attachment(http, remote_name, &file_name).await.unwrap_or(false) {
if adopted
&& has_attachment(http, remote_name, &file_name)
.await
.unwrap_or(false)
{
return Ok(());
}
let fields = [
@@ -772,14 +907,24 @@ async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf,
let resp = http
.post_file(
&["api", "method", "upload_file"],
&Upload { file_name: &file_name, mime: "application/pdf", bytes: &pdf.bytes, fields: &fields },
&Upload {
file_name: &file_name,
mime: "application/pdf",
bytes: &pdf.bytes,
fields: &fields,
},
)
.await?;
let ok = resp.get("message").map(|m| m.get("name").is_some() || m.get("file_url").is_some()).unwrap_or(false);
let ok = resp
.get("message")
.map(|m| m.get("name").is_some() || m.get("file_url").is_some())
.unwrap_or(false);
if ok {
Ok(())
} else {
Err(ErpError::protocol("ERPNext did not confirm the PDF upload."))
Err(ErpError::protocol(
"ERPNext did not confirm the PDF upload.",
))
}
}
@@ -797,7 +942,14 @@ struct Progress {
warnings: Vec<String>,
}
fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, status: &str, error: &str) -> Result<(), ErpError> {
fn persist(
db: &Db,
invoice_id: i64,
prev: Option<&SyncRow>,
st: &Progress,
status: &str,
error: &str,
) -> Result<(), ErpError> {
let synced_at = if status == "synced" {
Some(chrono::Utc::now().to_rfc3339())
} else {
@@ -818,7 +970,12 @@ fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, stat
/// The message shown to the user: which step failed, then the error's own readable text. Local refusals and
/// conflicts already say what is wrong and get no prefix.
fn failure_text(step: &str, e: &ErpError) -> String {
if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) {
if step.is_empty()
|| matches!(
e.kind,
ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict
)
{
e.to_string()
} else if e.kind == ErrorKind::Validation && e.message.contains("cannot be a fraction") {
// ERPNext names the row and the UOM already; say what to change in Voiced's settings.
@@ -828,7 +985,13 @@ fn failure_text(step: &str, e: &ErpError) -> String {
}
}
async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st: &mut Progress) -> Result<(), ErpError> {
async fn run_push(
db: &Db,
http: &ErpClient,
l: &Loaded,
want_submit: bool,
st: &mut Progress,
) -> Result<(), ErpError> {
let inv = &l.invoice;
let cfg = &l.cfg;
let prev = l.sync.as_ref();
@@ -882,7 +1045,10 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
}
None => {
st.payload_hash = hash;
if cfg.naming_mode == NamingMode::Mirror && l.india_compliance && !ic_number_ok(&inv.number) {
if cfg.naming_mode == NamingMode::Mirror
&& l.india_compliance
&& !ic_number_ok(&inv.number)
{
return Err(pre(format!(
"Invoice number {} is longer than 16 characters or has characters India Compliance refuses (letters, digits, - and / only), so ERPNext would reject it. Use the ERPNext series naming mode, or start a new Voiced series with a shorter prefix.",
inv.number
@@ -911,7 +1077,11 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
if let (Some(w), None) = (&l.pdf_warning, &l.pdf) {
st.warnings.push(w.clone());
}
if existing.is_some() && prev.is_some_and(|p| p.status == "synced") && !need_submit && pdf.is_none() {
if existing.is_some()
&& prev.is_some_and(|p| p.status == "synced")
&& !need_submit
&& pdf.is_none()
{
st.no_op = true;
return Ok(());
}
@@ -933,7 +1103,13 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
Ok(())
}
pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_id: i64, submit: Option<bool>) -> PushResult {
pub async fn push_invoice(
db: &Db,
local_dir: &Path,
http: &ErpClient,
invoice_id: i64,
submit: Option<bool>,
) -> PushResult {
let loaded = match load_for_push(db, local_dir, invoice_id) {
Ok(l) => l,
Err((number, e)) => return PushResult::refused(invoice_id, &number, e),
@@ -946,7 +1122,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
let (error, status): (Option<(ErpError, String)>, &str) = match outcome {
Ok(()) => {
let write = if st.no_op { Ok(()) } else { persist(db, invoice_id, prev, &st, "synced", "") };
let write = if st.no_op {
Ok(())
} else {
persist(db, invoice_id, prev, &st, "synced", "")
};
match write {
Ok(()) => (None, "synced"),
Err(e) => {
@@ -957,7 +1137,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
}
Err(e) => {
let text = failure_text(st.step, &e);
let status = if e.kind == ErrorKind::Conflict { "conflict" } else { "error" };
let status = if e.kind == ErrorKind::Conflict {
"conflict"
} else {
"error"
};
// Best effort: the original error is what the caller needs to see.
let _ = persist(db, invoice_id, prev, &st, status, &text);
(Some((e, text)), status)
@@ -1022,23 +1206,44 @@ pub struct PaymentEntryInput<'a> {
/// `difference_amount` is 0, `total_allocated_amount = cash + TDS` and the invoice's outstanding drops by
/// cash + TDS. A partial payment without TDS leaves the rest outstanding.
pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result<Value, String> {
let mut doc: Map<String, Value> = draft.as_object().cloned().ok_or("ERPNext returned an unexpected payment draft.")?;
let mut doc: Map<String, Value> = draft
.as_object()
.cloned()
.ok_or("ERPNext returned an unexpected payment draft.")?;
doc.retain(|k, _| !k.starts_with("__"));
doc.insert("doctype".into(), json!("Payment Entry"));
doc.insert("posting_date".into(), json!(p.paid_on));
let reference = if p.reference.trim().is_empty() { format!("Voiced payment {}", p.payment_id) } else { p.reference.trim().to_string() };
let reference = if p.reference.trim().is_empty() {
format!("Voiced payment {}", p.payment_id)
} else {
p.reference.trim().to_string()
};
doc.insert("reference_no".into(), json!(reference));
doc.insert("reference_date".into(), json!(p.paid_on));
doc.insert("paid_amount".into(), mapping::money(p.cash_paise));
doc.insert("received_amount".into(), mapping::money(p.cash_paise));
doc.insert("remarks".into(), json!(format!("Voiced payment {} for invoice {}", p.payment_id, p.invoice_number)));
doc.insert(
"remarks".into(),
json!(format!(
"Voiced payment {} for invoice {}",
p.payment_id, p.invoice_number
)),
);
let allocated = mapping::money(p.cash_paise + p.tds_paise);
let refs = doc.get_mut("references").and_then(Value::as_array_mut).ok_or("ERPNext returned no invoice reference for this payment.")?;
let refs = doc
.get_mut("references")
.and_then(Value::as_array_mut)
.ok_or("ERPNext returned no invoice reference for this payment.")?;
let target = refs
.iter_mut()
.find(|r| r.get("reference_name").and_then(Value::as_str) == Some(p.remote_invoice))
.ok_or_else(|| format!("ERPNext's payment draft does not reference {}.", p.remote_invoice))?;
.ok_or_else(|| {
format!(
"ERPNext's payment draft does not reference {}.",
p.remote_invoice
)
})?;
target["allocated_amount"] = allocated;
let deductions = if p.tds_paise > 0 {
@@ -1100,7 +1305,11 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
.map_err(|e| e.to_string())?
.ok_or_else(|| "Payment not found".to_string())?;
let number: String = conn
.query_row("SELECT number FROM invoices WHERE id = ?1", params![row.invoice_id], |r| r.get(0))
.query_row(
"SELECT number FROM invoices WHERE id = ?1",
params![row.invoice_id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
let sync = load_sync(conn, row.invoice_id)?;
let cfg = config::load(conn)?;
@@ -1144,21 +1353,33 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
));
}
if cfg.payment_bank_account.trim().is_empty() {
return fail(pre("Set the payment bank account in the ERPNext settings first."));
return fail(pre(
"Set the payment bank account in the ERPNext settings first.",
));
}
if row.tds_paise > 0 && cfg.tds_account.trim().is_empty() {
return fail(pre("This payment has TDS: set the TDS account in the ERPNext settings first."));
return fail(pre(
"This payment has TDS: set the TDS account in the ERPNext settings first.",
));
}
let query = [
("dt", DOCTYPE_INVOICE.to_string()),
("dn", remote_invoice.clone()),
("bank_account", cfg.payment_bank_account.trim().to_string()),
("party_amount", paise_to_decimal(row.amount_paise + row.tds_paise)),
(
"party_amount",
paise_to_decimal(row.amount_paise + row.tds_paise),
),
];
let draft = match http.get(&["api", "method", GET_PAYMENT_ENTRY], &query).await {
let draft = match http
.get(&["api", "method", GET_PAYMENT_ENTRY], &query)
.await
{
Ok(v) => v.get("message").cloned().unwrap_or(Value::Null),
Err(e) => return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e),
Err(e) => {
return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e)
}
};
let body = match build_payment_entry(
&draft,
@@ -1178,17 +1399,27 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
Err(e) => return fail(ErpError::protocol(e)),
};
// Not retried after a 5xx or timeout: it may have been created, and a second entry would double-count.
let resp = match http.post(&["api", "resource", "Payment Entry"], &body, false).await {
let resp = match http
.post(&["api", "resource", "Payment Entry"], &body, false)
.await
{
Ok(v) => v,
Err(e) => return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e),
Err(e) => {
return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e)
}
};
let Some(name) = doc_name(&resp) else {
return fail(ErpError::protocol("ERPNext did not return the new Payment Entry's name."));
return fail(ErpError::protocol(
"ERPNext did not return the new Payment Entry's name.",
));
};
if let Err(e) = with_db(db, |c| {
c.execute("UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2", params![name, payment_id])
.map(|_| ())
.map_err(|e| e.to_string())
c.execute(
"UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2",
params![name, payment_id],
)
.map(|_| ())
.map_err(|e| e.to_string())
}) {
return fail(e);
}
@@ -1223,7 +1454,14 @@ impl InvoiceSink for ErpnextSink<'_> {
request: PushRequest,
) -> impl std::future::Future<Output = Result<PushedInvoice, String>> + Send + 'a {
async move {
let r = push_invoice(self.db, self.local_dir, self.http, request.invoice_id, request.submit).await;
let r = push_invoice(
self.db,
self.local_dir,
self.http,
request.invoice_id,
request.submit,
)
.await;
if r.ok {
Ok(PushedInvoice {
remote_name: r.remote_name,
@@ -1244,7 +1482,10 @@ impl InvoiceSink for ErpnextSink<'_> {
async move {
let r = push_payment(self.db, self.http, request.payment_id).await;
match (r.ok, r.entry_name) {
(true, Some(name)) => Ok(PushedPayment { remote_name: name, created: !r.already_synced }),
(true, Some(name)) => Ok(PushedPayment {
remote_name: name,
created: !r.already_synced,
}),
_ => Err(r.error.unwrap_or_else(|| "The payment push failed.".into())),
}
}
File diff suppressed because it is too large Load Diff
+28 -5
View File
@@ -18,7 +18,12 @@ pub struct Reply {
}
pub fn reply(status: u16, body: Value) -> Reply {
Reply { status, headers: vec![], body: body.to_string(), delay_ms: 0 }
Reply {
status,
headers: vec![],
body: body.to_string(),
delay_ms: 0,
}
}
pub struct Mock {
@@ -33,7 +38,12 @@ pub async fn serve(script: Vec<Reply>) -> Mock {
let script = Arc::new(script);
serve_fn(move |i, _| {
let r = &script[i.min(script.len() - 1)];
Reply { status: r.status, headers: r.headers.clone(), body: r.body.clone(), delay_ms: r.delay_ms }
Reply {
status: r.status,
headers: r.headers.clone(),
body: r.body.clone(),
delay_ms: r.delay_ms,
}
})
.await
}
@@ -48,7 +58,9 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st
let handler = Arc::new(handler);
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let (h, r, handler) = (h.clone(), r.clone(), handler.clone());
tokio::spawn(async move {
let mut buf = Vec::new();
@@ -97,7 +109,11 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st
});
}
});
Mock { base: format!("http://127.0.0.1:{port}"), hits, requests }
Mock {
base: format!("http://127.0.0.1:{port}"),
hits,
requests,
}
}
/// Short timeouts and near-zero backoff so retry tests run in milliseconds.
@@ -113,5 +129,12 @@ pub fn fast_opts() -> ClientOptions {
}
pub fn client(mock: &Mock) -> ErpClient {
ErpClient::new(&mock.base, "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap()
ErpClient::new(
&mock.base,
"thekey",
&Secret::new("thesecret"),
"",
fast_opts(),
)
.unwrap()
}