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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user