diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index de44540..4459920 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -509,6 +509,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -532,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -545,10 +555,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -875,6 +891,20 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -1244,6 +1274,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1415,6 +1456,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1531,6 +1591,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1541,6 +1602,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -1560,9 +1636,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1891,6 +1969,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2090,6 +2198,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2152,6 +2270,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "ndk" version = "0.9.0" @@ -2439,6 +2563,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2866,21 +2996,31 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ "base64 0.23.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", + "mime_guess", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2916,6 +3056,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -2979,12 +3133,91 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2994,6 +3227,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3051,6 +3293,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.38.0" @@ -3175,6 +3440,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.24.0" @@ -3272,6 +3549,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.4" @@ -3396,6 +3689,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -3459,6 +3758,27 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3480,7 +3800,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3490,7 +3810,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3546,7 +3866,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3763,7 +4083,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3786,7 +4106,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3972,9 +4292,31 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3984,6 +4326,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -4217,6 +4560,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -4229,6 +4578,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4299,8 +4654,10 @@ dependencies = [ "flate2", "image", "percent-encoding", + "reqwest", "rusqlite", "rusqlite_migration", + "rustls", "serde", "serde_json", "sha2", @@ -4312,6 +4669,7 @@ dependencies = [ "tauri-plugin-window-state", "tempfile", "thiserror 2.0.21", + "tokio", "uuid", "zip", ] @@ -4515,6 +4873,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.39.1" @@ -4679,6 +5046,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4706,6 +5084,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4995,7 +5382,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5156,6 +5543,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ed20944..f705b59 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,11 @@ flate2 = "1" zip = { version = "8", default-features = false, features = ["deflate-flate2"] } percent-encoding = "2" image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } +# ERPNext integration: all HTTP runs in Rust. TLS is rustls with the `ring` provider only (no aws-lc, +# which does not cross-compile cleanly under cargo-xwin); roots come from the OS via rustls-platform-verifier. +reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "http2", "charset", "system-proxy", "json", "multipart", "query"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +tokio = { version = "1", features = ["time"] } # Desktop-only plugins (not available on mobile targets). [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] @@ -39,6 +44,7 @@ tauri-plugin-window-state = "2" [dev-dependencies] tempfile = "3" +tokio = { version = "1", features = ["rt", "macros", "net", "io-util", "time"] } [features] # Production builds embed the frontend and serve it over the custom asset diff --git a/src-tauri/src/commands/erpnext.rs b/src-tauri/src/commands/erpnext.rs new file mode 100644 index 0000000..c982bce --- /dev/null +++ b/src-tauri/src/commands/erpnext.rs @@ -0,0 +1,153 @@ +//! Tauri commands for the ERPNext settings: read/save the configuration, test a connection and load the +//! lists the settings dropdowns need. The API secret never leaves this module: `erpnext_get_config` returns +//! `apiSecretSet` only, and every command resolves a blank secret to the stored one on the Rust side. +//! Pushing invoices is a later step. + +use crate::integrations::erpnext::client::ErpClient; +use crate::integrations::erpnext::config::{self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView}; +use crate::integrations::erpnext::discovery::{self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts}; +use crate::AppState; +use rusqlite::Connection; +use tauri::State; + +pub fn get_config_impl(conn: &Connection) -> Result { + Ok(config::load(conn)?.view()) +} + +/// Merges the form with the stored row (blank secret keeps the stored one) without saving. +pub fn resolve_config(conn: &Connection, input: ErpnextConfigInput) -> Result { + let stored = config::load(conn)?; + input.resolve(&stored) +} + +pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result { + let cfg = resolve_config(conn, input)?; + config::save(conn, &cfg)?; + Ok(cfg.view()) +} + +/// What the connection test needs from the local database. +pub fn local_facts(conn: &Connection) -> Result { + let registration: String = conn + .query_row("SELECT gst_registration FROM app_settings WHERE id = 1", [], |r| r.get(0)) + .map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare("SELECT number FROM invoices ORDER BY id") + .map_err(|e| e.to_string())?; + let invalid_numbers = stmt + .query_map([], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())? + .collect::>>() + .map_err(|e| e.to_string())? + .into_iter() + .filter(|n| !ic_number_ok(n)) + .take(5) + .collect(); + let next_number = conn + .query_row( + "SELECT prefix, padding, next_number FROM invoice_series WHERE is_active = 1 ORDER BY id DESC LIMIT 1", + [], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)), + ) + .ok() + .map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next)); + Ok(LocalFacts { vendor_registered: registration != "unregistered", invalid_numbers, next_number }) +} + +#[tauri::command] +pub fn erpnext_get_config(state: State) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + get_config_impl(&conn) +} + +/// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it. +#[tauri::command] +pub fn erpnext_save_config(state: State, config: ErpnextConfigInput) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + save_config_impl(&conn, config) +} + +/// Tests the form values as typed (saved or not). The database lock is released before any network call. +#[tauri::command] +pub async fn erpnext_test_connection( + state: State<'_, AppState>, + config: ErpnextConfigInput, +) -> Result { + let (cfg, local) = { + let conn = state.db.lock().map_err(|e| e.to_string())?; + (resolve_config(&conn, config)?, local_facts(&conn)?) + }; + let client = ErpClient::from_config(&cfg)?; + Ok(discovery::test_connection(&client, &cfg, &local).await?) +} + +/// Loads the dropdown lists. Company-scoped lists use `config.company`. +#[tauri::command] +pub async fn erpnext_load_options( + state: State<'_, AppState>, + config: ErpnextConfigInput, +) -> Result { + let cfg = { + let conn = state.db.lock().map_err(|e| e.to_string())?; + resolve_config(&conn, config)? + }; + let client = ErpClient::from_config(&cfg)?; + Ok(client.load_options(&cfg.company).await?) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn input(extra: serde_json::Value) -> ErpnextConfigInput { + let mut base = json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Arun Co" }); + base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + serde_json::from_value(base).unwrap() + } + + #[test] + fn saved_config_is_returned_without_the_secret() { + let conn = crate::db::open_in_memory().unwrap(); + let view = save_config_impl(&conn, input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true }))).unwrap(); + assert!(view.api_secret_set); + let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap(); + assert!(!shown.contains("super-secret-value")); + assert!(shown.contains("\"apiSecretSet\":true")); + assert!(shown.contains("\"company\":\"Arun Co\"")); + + // Saving again with a blank secret keeps it for the connection test too. + save_config_impl(&conn, input(json!({ "company": "Other Co" }))).unwrap(); + let resolved = resolve_config(&conn, input(json!({}))).unwrap(); + assert_eq!(resolved.api_secret.expose(), "super-secret-value"); + assert_eq!(resolved.company, "Arun Co"); + } + + #[test] + fn test_values_are_resolved_without_saving() { + let conn = crate::db::open_in_memory().unwrap(); + let resolved = resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap(); + assert_eq!(resolved.api_secret.expose(), "typed-not-saved"); + assert!(!get_config_impl(&conn).unwrap().api_secret_set); + } + + #[test] + fn local_facts_report_registration_numbers_and_the_next_number() { + let conn = crate::db::open_in_memory().unwrap(); + let facts = local_facts(&conn).unwrap(); + assert!(!facts.vendor_registered); + assert!(facts.invalid_numbers.is_empty()); + assert!(facts.next_number.is_some()); + + conn.execute("UPDATE app_settings SET gst_registration = 'regular'", []).unwrap(); + conn.execute( + "INSERT INTO invoices (number, invoice_date, created_at, updated_at) + VALUES ('AP/2026-001', '2026-04-01', 'n', 'n'), ('INVOICE/2026/000001', '2026-04-01', 'n', 'n')", + [], + ) + .unwrap(); + let facts = local_facts(&conn).unwrap(); + assert!(facts.vendor_registered); + assert_eq!(facts.invalid_numbers, ["INVOICE/2026/000001"]); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 28322f1..1d6f26c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod archive; pub mod assets; pub mod backup; pub mod clients; +pub mod erpnext; pub mod files; pub mod fonts; pub mod invoice; diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index a4bb3b1..462a8bf 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10; const BACKUP_PREFIX: &str = "voiced-pre-v"; /// Highest schema version, i.e. the number of entries in `migrations()`. -pub(crate) const LATEST_VERSION: i64 = 9; +pub(crate) const LATEST_VERSION: i64 = 10; #[derive(Debug, thiserror::Error)] pub enum DbError { @@ -270,6 +270,61 @@ const M9: &str = r#" ALTER TABLE app_settings ADD COLUMN auto_backup INTEGER NOT NULL DEFAULT 1; "#; +/// Version 10: ERPNext integration. `erpnext_config` is a single row (inserted here, so reading it is a plain SELECT) +/// holding the connection, the company/account/master mapping and the toggles. `api_secret` is stored here and is never +/// returned to the webview. `erpnext_sync` tracks what was pushed per invoice; issued invoices themselves are untouched. +/// The remote names on clients, payments and item presets are nullable: NULL means "not linked yet". +const M10: &str = r#" +CREATE TABLE erpnext_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + base_url TEXT NOT NULL DEFAULT '', + api_key TEXT NOT NULL DEFAULT '', + api_secret TEXT NOT NULL DEFAULT '', + extra_ca_pem TEXT NOT NULL DEFAULT '', + company TEXT NOT NULL DEFAULT '', + company_address TEXT NOT NULL DEFAULT '', + naming_mode TEXT NOT NULL DEFAULT 'mirror' CHECK (naming_mode IN ('mirror', 'series')), + naming_series TEXT NOT NULL DEFAULT '', + income_account TEXT NOT NULL DEFAULT '', + cost_center TEXT NOT NULL DEFAULT '', + cgst_account TEXT NOT NULL DEFAULT '', + sgst_account TEXT NOT NULL DEFAULT '', + utgst_account TEXT NOT NULL DEFAULT '', + igst_account TEXT NOT NULL DEFAULT '', + tax_template_intra TEXT NOT NULL DEFAULT '', + tax_template_inter TEXT NOT NULL DEFAULT '', + payment_bank_account TEXT NOT NULL DEFAULT '', + tds_account TEXT NOT NULL DEFAULT '', + default_item_code TEXT NOT NULL DEFAULT '', + uom_map TEXT NOT NULL DEFAULT '{"second":"Second","minute":"Minute","hour":"Hour","session":"Nos","unit":"Nos"}', + customer_group TEXT NOT NULL DEFAULT '', + territory TEXT NOT NULL DEFAULT '', + selling_price_list TEXT NOT NULL DEFAULT '', + submit_on_push INTEGER NOT NULL DEFAULT 0, + attach_pdf INTEGER NOT NULL DEFAULT 1, + auto_push_on_issue INTEGER NOT NULL DEFAULT 0, + create_missing_customers INTEGER NOT NULL DEFAULT 1, + last_detect_result TEXT NOT NULL DEFAULT '', + updated_at TEXT +); +INSERT INTO erpnext_config (id) VALUES (1); + +CREATE TABLE erpnext_sync ( + invoice_id INTEGER PRIMARY KEY REFERENCES invoices(id), + remote_name TEXT NOT NULL DEFAULT '', + remote_docstatus INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'error')), + last_error TEXT NOT NULL DEFAULT '', + payload_hash TEXT NOT NULL DEFAULT '', + synced_at TEXT +); + +ALTER TABLE clients ADD COLUMN erpnext_customer TEXT; +ALTER TABLE clients ADD COLUMN erpnext_address TEXT; +ALTER TABLE payments ADD COLUMN erpnext_payment_entry TEXT; +ALTER TABLE item_presets ADD COLUMN erpnext_item_code TEXT; +"#; + fn migrations() -> Migrations<'static> { Migrations::new(vec![ M::up(SCHEMA), @@ -281,6 +336,7 @@ fn migrations() -> Migrations<'static> { M::up(M7), M::up(M8), M::up(M9), + M::up(M10), ]) } @@ -786,6 +842,81 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert_eq!(rows[2].5, "unregistered"); } + #[test] + fn m10_fresh_database_has_the_erpnext_tables_and_a_default_config_row() { + let conn = open_in_memory().unwrap(); + let (naming, submit, attach, auto_push, create_customers, secret, uom): (String, i64, i64, i64, i64, String, String) = conn + .query_row( + "SELECT naming_mode, submit_on_push, attach_pdf, auto_push_on_issue, create_missing_customers, api_secret, uom_map + FROM erpnext_config WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)), + ) + .unwrap(); + assert_eq!((naming.as_str(), submit, attach, auto_push, create_customers), ("mirror", 0, 1, 0, 1)); + assert_eq!(secret, ""); + assert!(serde_json::from_str::(&uom).is_ok()); + let rows: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0)).unwrap(); + assert_eq!(rows, 1); + // Single row only. + assert!(conn.execute("INSERT INTO erpnext_config (id) VALUES (2)", []).is_err()); + conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync") + .unwrap(); + conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients").unwrap(); + conn.prepare("SELECT erpnext_payment_entry FROM payments").unwrap(); + conn.prepare("SELECT erpnext_item_code FROM item_presets").unwrap(); + } + + #[test] + fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() { + let mut conn = Connection::open_in_memory().unwrap(); + let v9 = vec![ + M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), + ]; + Migrations::new(v9).to_latest(&mut conn).unwrap(); + assert_eq!(user_version(&conn), 9); + conn.execute("INSERT INTO clients (name, gstin, created_at) VALUES ('Acme', '29ABCDE1234F1Z5', 'now')", []).unwrap(); + conn.execute("INSERT INTO item_presets (description, rate_paise, created_at) VALUES ('Design', 500000, 'now')", []).unwrap(); + conn.execute( + "INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('AP/2026-001', '2026-04-01', 'now', 'now')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO payments (invoice_id, paid_on, amount_paise, mode, created_at) VALUES (1, '2026-04-10', 12345, 'upi', 'now')", + [], + ) + .unwrap(); + + migrations().to_latest(&mut conn).unwrap(); + assert_eq!(user_version(&conn), 10); + + let (name, gstin, customer, address): (String, String, Option, Option) = conn + .query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + }) + .unwrap(); + assert_eq!((name.as_str(), gstin.as_str(), customer, address), ("Acme", "29ABCDE1234F1Z5", None, None)); + let (desc, rate, code): (String, i64, Option) = conn + .query_row("SELECT description, rate_paise, erpnext_item_code FROM item_presets", [], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + }) + .unwrap(); + assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None)); + let (paid, entry): (i64, Option) = conn + .query_row("SELECT amount_paise, erpnext_payment_entry FROM payments", [], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap(); + assert_eq!((paid, entry), (12345, None)); + let number: String = conn.query_row("SELECT number FROM invoices", [], |r| r.get(0)).unwrap(); + assert_eq!(number, "AP/2026-001"); + + // The config row exists with defaults, and a sync row can reference the invoice. + let enabled: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config WHERE id = 1", [], |r| r.get(0)).unwrap(); + assert_eq!(enabled, 1); + conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'AP/2026-001', 'synced')", []).unwrap(); + assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", []).is_err()); + } + fn build_v1_database(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch(V1_SCHEMA).unwrap(); diff --git a/src-tauri/src/integrations/erpnext/client.rs b/src-tauri/src/integrations/erpnext/client.rs new file mode 100644 index 0000000..b63f62e --- /dev/null +++ b/src-tauri/src/integrations/erpnext/client.rs @@ -0,0 +1,484 @@ +//! HTTP client for a Frappe/ERPNext site. All requests run here, in Rust: a webview `fetch` from +//! `tauri://localhost` would be blocked by CORS. +//! +//! TLS is rustls with the `ring` provider (installed once, process-wide). Roots come from the OS through +//! rustls-platform-verifier; an optional extra CA PEM is merged in for self-hosted sites. + +use super::config::{ErpnextConfig, Secret}; +use super::errors::{ErpError, ErrorKind}; +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, RETRY_AFTER}; +use reqwest::{Method, Url}; +use serde_json::Value; +use std::sync::Once; +use std::time::Duration; + +static CRYPTO_PROVIDER: Once = Once::new(); + +/// Installs the ring crypto provider for rustls. Idempotent; must run before the first client is built. +pub fn ensure_crypto_provider() { + CRYPTO_PROVIDER.call_once(|| { + // Err means another provider is already installed, which is fine for our purposes. + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +#[derive(Debug, Clone)] +pub struct ClientOptions { + pub connect_timeout: Duration, + pub read_timeout: Duration, + pub total_timeout: Duration, + /// Retries after the first attempt. + pub max_retries: u32, + pub backoff_base: Duration, + pub max_backoff: Duration, +} + +impl Default for ClientOptions { + fn default() -> Self { + ClientOptions { + connect_timeout: Duration::from_secs(10), + read_timeout: Duration::from_secs(30), + total_timeout: Duration::from_secs(60), + max_retries: 3, + backoff_base: Duration::from_millis(500), + max_backoff: Duration::from_secs(8), + } + } +} + +/// 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") +} + +/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash. +/// A missing scheme means https, except for local development hosts, which get http. +pub fn normalize_base_url(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err(ErpError::config("The ERPNext address is empty.")); + } + let with_scheme = if raw.contains("://") { + raw.to_string() + } else { + let host = raw.split(['/', ':']).next().unwrap_or(""); + 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 host = url + .host_str() + .ok_or_else(|| ErpError::config("The ERPNext address has no host name."))? + .to_string(); + match url.scheme() { + "https" => {} + "http" => { + if !is_local_dev_host(&host) { + return Err(ErpError::config( + "Use an https:// address. Plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test.", + )); + } + } + 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.")); + } + url.set_query(None); + url.set_fragment(None); + Ok(url.as_str().trim_end_matches('/').to_string()) +} + +pub struct ErpClient { + http: reqwest::Client, + base: Url, + opts: ClientOptions, + /// Strings scrubbed from every error message. + secrets: Vec, +} + +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() + } +} + +impl ErpClient { + pub fn new( + base_url: &str, + api_key: &str, + api_secret: &Secret, + extra_ca_pem: &str, + opts: ClientOptions, + ) -> Result { + ensure_crypto_provider(); + 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."))?; + auth.set_sensitive(true); + + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, auth); + // Frappe answers `Accept: text/*` with HTML; always ask for JSON. + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + + let mut builder = reqwest::Client::builder() + .default_headers(headers) + .user_agent(concat!("Voiced/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(opts.connect_timeout) + .read_timeout(opts.read_timeout) + .timeout(opts.total_timeout) + // A redirect drops the body or the auth header; surface it instead of guessing. + .redirect(reqwest::redirect::Policy::none()); + if !extra_ca_pem.trim().is_empty() { + 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.")); + } + 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))))?; + Ok(ErpClient { + http, + base, + opts, + secrets: vec![api_secret.expose().to_string(), api_key.trim().to_string()], + }) + } + + pub fn from_config(cfg: &ErpnextConfig) -> Result { + Self::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, &cfg.extra_ca_pem, ClientOptions::default()) + } + + pub fn base_url(&self) -> &str { + self.base.as_str().trim_end_matches('/') + } + + fn url(&self, segments: &[&str]) -> Url { + let mut url = self.base.clone(); + if let Ok(mut path) = url.path_segments_mut() { + path.pop_if_empty().extend(segments); + } + url + } + + /// GET, retried on 429, 5xx and timeouts. + pub async fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result { + self.send(Method::GET, segments, query, 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 { + self.send(Method::POST, segments, &[], Some(body), idempotent).await + } + + async fn send( + &self, + method: Method, + segments: &[&str], + query: &[(&str, String)], + body: Option<&Value>, + retry_ambiguous: bool, + ) -> Result { + let mut attempt: u32 = 0; + loop { + let mut req = self.http.request(method.clone(), self.url(segments)); + if !query.is_empty() { + req = req.query(query); + } + if let Some(b) = body { + req = req.json(b); + } + let can_retry = attempt < self.opts.max_retries; + match req.send().await { + Ok(resp) => { + let status = resp.status(); + let retry_after = resp + .headers() + .get(RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()) + .map(Duration::from_secs); + let location = resp + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let text = match resp.text().await { + Ok(t) => t, + Err(e) => { + if e.is_timeout() && retry_ambiguous && can_retry { + self.backoff(attempt, None).await; + attempt += 1; + continue; + } + return Err(self.map_transport(&e)); + } + }; + if status.is_success() { + return self.parse_success(&text); + } + let retryable = status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous); + if retryable && can_retry { + self.backoff(attempt, retry_after).await; + attempt += 1; + continue; + } + if status.is_redirection() { + let to = location.unwrap_or_else(|| "another address".to_string()); + return Err(self.scrub(ErpError::protocol(format!( + "The server redirected the request to {to}. Use the final address (usually https://) in the ERPNext address field." + )))); + } + return Err(self.scrub(ErpError::from_response(status.as_u16(), &text))); + } + Err(e) => { + if e.is_timeout() && retry_ambiguous && can_retry { + self.backoff(attempt, None).await; + attempt += 1; + continue; + } + return Err(self.map_transport(&e)); + } + } + } + } + + async fn backoff(&self, attempt: u32, retry_after: Option) { + 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; + } + + fn parse_success(&self, text: &str) -> Result { + if text.trim().is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(text).map_err(|_| { + ErpError::protocol( + "The server did not return JSON. Check the address: it should be the root of your ERPNext site.", + ) + }) + } + + fn scrub(&self, err: ErpError) -> ErpError { + let secrets: Vec<&str> = self.secrets.iter().map(String::as_str).collect(); + err.redacted(&secrets) + } + + 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.") + } else if e.is_connect() { + let lower = chain.to_ascii_lowercase(); + let hint = if lower.contains("certificate") || lower.contains("unknownissuer") { + " The certificate is not trusted: paste the site's CA certificate into the extra CA field." + } else { + "" + }; + 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}).")) + }; + self.scrub(err) + } +} + +/// `a: b: c` for an error and its sources. +fn error_chain(e: &dyn std::error::Error) -> String { + let mut parts = vec![e.to_string()]; + let mut source = e.source(); + while let Some(s) = source { + let text = s.to_string(); + if parts.last() != Some(&text) { + parts.push(text); + } + source = s.source(); + } + parts.join(": ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::integrations::erpnext::testutil::{client, fast_opts, reply, serve, Reply}; + use serde_json::json; + use std::sync::atomic::Ordering; + + // ---- URL rules ---- + + #[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"); + } + + #[test] + fn http_is_only_for_local_development_hosts() { + for ok in [ + "http://localhost:8000", + "http://127.0.0.1:8080", + "http://mysite.localhost:8000", + "http://erp.test", + "localhost:8000", + "127.0.0.1:8000", + ] { + 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"] { + 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://"] { + assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused"); + } + } + + #[tokio::test] + async fn sends_token_auth_and_a_json_accept_header() { + let mock = serve(vec![reply(200, json!({ "message": "arun@example.com" }))]).await; + let value = client(&mock) + .get(&["api", "method", "frappe.auth.get_logged_user"], &[]) + .await + .unwrap(); + assert_eq!(value["message"], "arun@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.contains("accept: application/json"), "{raw}"); + } + + #[tokio::test] + async fn path_segments_are_percent_encoded_once() { + let mock = serve(vec![reply(200, json!({ "data": [] }))]).await; + client(&mock) + .get(&["api", "resource", "Sales Invoice", "AP/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/AP%2F2026-001?limit_page_length=0 "), "{raw}"); + } + + #[tokio::test] + async fn retries_429_then_succeeds() { + 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(); + 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(); + assert_eq!(err.kind, ErrorKind::Server); + assert_eq!(err.message, "boom"); + assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries"); + } + + #[tokio::test] + async fn validation_errors_are_not_retried() { + 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) + .await + .unwrap_err(); + assert_eq!(err.kind, ErrorKind::Validation); + assert_eq!(err.message, "Rate is required"); + assert_eq!(mock.hits.load(Ordering::SeqCst), 1); + } + + #[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 mock = serve(vec![slow]).await; + 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 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"); + } + + #[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 value = client(&mock) + .post(&["api", "resource", "Sales Invoice"], &json!({}), false) + .await + .unwrap(); + assert_eq!(value["data"]["name"], "X"); + assert_eq!(mock.hits.load(Ordering::SeqCst), 2); + + let mock = serve(vec![reply(500, json!({}))]).await; + let err = client(&mock) + .post(&["api", "resource", "Sales Invoice"], &json!({}), false) + .await + .unwrap_err(); + assert_eq!(err.kind, ErrorKind::Server); + assert_eq!(mock.hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn redirects_are_reported_not_followed() { + let mut moved = reply(301, json!({})); + 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(); + assert_eq!(err.kind, ErrorKind::Protocol); + assert!(err.message.contains("https://erp.example.com/"), "{}", err.message); + assert_eq!(mock.hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn html_success_page_is_a_protocol_error() { + let mut page = reply(200, json!({})); + page.body = "login".into(); + let mock = serve(vec![page]).await; + let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err(); + assert_eq!(err.kind, ErrorKind::Protocol); + } + + #[tokio::test] + async fn refused_connection_is_a_network_error_without_the_secret() { + 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 err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err(); + assert_eq!(err.kind, ErrorKind::Network); + assert!(!err.to_string().contains("thesecret")); + assert!(!format!("{c:?}").contains("thesecret")); + } + + #[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_eq!(err.kind, ErrorKind::Config); + } +} diff --git a/src-tauri/src/integrations/erpnext/config.rs b/src-tauri/src/integrations/erpnext/config.rs new file mode 100644 index 0000000..7ad3186 --- /dev/null +++ b/src-tauri/src/integrations/erpnext/config.rs @@ -0,0 +1,562 @@ +//! ERPNext connection and mapping settings (the single `erpnext_config` row). +//! +//! The API secret lives in the SQLite DB and is never handed back to the webview: `ErpnextConfigView` +//! carries only `api_secret_set`. `Secret` redacts itself in `Debug`, so a stray `{:?}` cannot leak it. + +use super::client::normalize_base_url; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +/// A credential that never prints itself. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct Secret(String); + +impl Secret { + pub fn new(value: impl Into) -> Self { + Secret(value.into()) + } + + pub fn expose(&self) -> &str { + &self.0 + } + + pub fn is_set(&self) -> bool { + !self.0.is_empty() + } +} + +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)" }) + } +} + +/// How the Sales Invoice gets its name in ERPNext. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NamingMode { + /// The Voiced number becomes the document name (API v2, Frappe >= 15.73). + Mirror, + /// ERPNext assigns its own series number; the Voiced number goes into `remarks`. + Series, +} + +impl NamingMode { + pub fn as_str(self) -> &'static str { + match self { + NamingMode::Mirror => "mirror", + NamingMode::Series => "series", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "mirror" => Ok(NamingMode::Mirror), + "series" => Ok(NamingMode::Series), + other => Err(format!("Unknown naming mode \"{other}\"")), + } + } +} + +/// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities, +/// which is why hours and minutes map to their own UOMs. +pub fn default_uom_map() -> BTreeMap { + [ + ("second", "Second"), + ("minute", "Minute"), + ("hour", "Hour"), + ("session", "Nos"), + ("unit", "Nos"), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +/// Everything stored for the integration, secret included. Backend-only. +#[derive(Debug, Clone)] +pub struct ErpnextConfig { + pub base_url: String, + pub api_key: String, + pub api_secret: Secret, + pub extra_ca_pem: String, + pub company: String, + pub company_address: String, + pub naming_mode: NamingMode, + pub naming_series: String, + pub income_account: String, + pub cost_center: String, + pub cgst_account: String, + pub sgst_account: String, + pub utgst_account: String, + pub igst_account: String, + pub tax_template_intra: String, + pub tax_template_inter: String, + pub payment_bank_account: String, + pub tds_account: String, + pub default_item_code: String, + pub uom_map: BTreeMap, + pub customer_group: String, + pub territory: String, + pub selling_price_list: String, + pub submit_on_push: bool, + pub attach_pdf: bool, + pub auto_push_on_issue: bool, + pub create_missing_customers: bool, + /// JSON of the last connection test (versions, IC flag, warnings); empty if never run. Never holds secrets. + pub last_detect_result: String, +} + +impl Default for ErpnextConfig { + fn default() -> Self { + ErpnextConfig { + base_url: String::new(), + api_key: String::new(), + api_secret: Secret::default(), + extra_ca_pem: String::new(), + company: String::new(), + company_address: String::new(), + naming_mode: NamingMode::Mirror, + naming_series: String::new(), + income_account: String::new(), + cost_center: String::new(), + cgst_account: String::new(), + sgst_account: String::new(), + utgst_account: String::new(), + igst_account: String::new(), + tax_template_intra: String::new(), + tax_template_inter: String::new(), + payment_bank_account: String::new(), + tds_account: String::new(), + default_item_code: String::new(), + uom_map: default_uom_map(), + customer_group: String::new(), + territory: String::new(), + selling_price_list: String::new(), + submit_on_push: false, + attach_pdf: true, + auto_push_on_issue: false, + create_missing_customers: true, + last_detect_result: String::new(), + } + } +} + +impl ErpnextConfig { + /// ERPNext UOM for a Voiced unit; falls back to the built-in default, then to "Nos". + pub fn uom_for(&self, unit: &str) -> String { + 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()) + } + + pub fn view(&self) -> ErpnextConfigView { + ErpnextConfigView { + base_url: self.base_url.clone(), + api_key: self.api_key.clone(), + api_secret_set: self.api_secret.is_set(), + extra_ca_pem: self.extra_ca_pem.clone(), + company: self.company.clone(), + company_address: self.company_address.clone(), + naming_mode: self.naming_mode, + naming_series: self.naming_series.clone(), + income_account: self.income_account.clone(), + cost_center: self.cost_center.clone(), + cgst_account: self.cgst_account.clone(), + sgst_account: self.sgst_account.clone(), + utgst_account: self.utgst_account.clone(), + igst_account: self.igst_account.clone(), + tax_template_intra: self.tax_template_intra.clone(), + tax_template_inter: self.tax_template_inter.clone(), + payment_bank_account: self.payment_bank_account.clone(), + tds_account: self.tds_account.clone(), + default_item_code: self.default_item_code.clone(), + uom_map: self.uom_map.clone(), + customer_group: self.customer_group.clone(), + territory: self.territory.clone(), + selling_price_list: self.selling_price_list.clone(), + submit_on_push: self.submit_on_push, + 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), + } + } +} + +/// What the webview sees: the same settings minus the secret. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ErpnextConfigView { + pub base_url: String, + pub api_key: String, + pub api_secret_set: bool, + pub extra_ca_pem: String, + pub company: String, + pub company_address: String, + pub naming_mode: NamingMode, + pub naming_series: String, + pub income_account: String, + pub cost_center: String, + pub cgst_account: String, + pub sgst_account: String, + pub utgst_account: String, + pub igst_account: String, + pub tax_template_intra: String, + pub tax_template_inter: String, + pub payment_bank_account: String, + pub tds_account: String, + pub default_item_code: String, + pub uom_map: BTreeMap, + pub customer_group: String, + pub territory: String, + pub selling_price_list: String, + pub submit_on_push: bool, + pub attach_pdf: bool, + pub auto_push_on_issue: bool, + pub create_missing_customers: bool, + pub last_detect_result: Value, +} + +/// What the webview sends to save or test. `api_secret` blank keeps the stored secret; a non-blank +/// value replaces it; `clear_secret` removes it. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErpnextConfigInput { + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub api_key: String, + #[serde(default)] + pub api_secret: String, + #[serde(default)] + pub clear_secret: bool, + #[serde(default)] + pub extra_ca_pem: String, + #[serde(default)] + pub company: String, + #[serde(default)] + pub company_address: String, + #[serde(default = "default_naming_mode")] + pub naming_mode: String, + #[serde(default)] + pub naming_series: String, + #[serde(default)] + pub income_account: String, + #[serde(default)] + pub cost_center: String, + #[serde(default)] + pub cgst_account: String, + #[serde(default)] + pub sgst_account: String, + #[serde(default)] + pub utgst_account: String, + #[serde(default)] + pub igst_account: String, + #[serde(default)] + pub tax_template_intra: String, + #[serde(default)] + pub tax_template_inter: String, + #[serde(default)] + pub payment_bank_account: String, + #[serde(default)] + pub tds_account: String, + #[serde(default)] + pub default_item_code: String, + #[serde(default)] + pub uom_map: BTreeMap, + #[serde(default)] + pub customer_group: String, + #[serde(default)] + pub territory: String, + #[serde(default)] + pub selling_price_list: String, + #[serde(default)] + pub submit_on_push: bool, + #[serde(default = "yes")] + pub attach_pdf: bool, + #[serde(default)] + pub auto_push_on_issue: bool, + #[serde(default = "yes")] + pub create_missing_customers: bool, + /// None keeps the stored value. + #[serde(default)] + pub last_detect_result: Option, +} + +fn default_naming_mode() -> String { + "mirror".to_string() +} + +fn yes() -> bool { + true +} + +fn origin_of(url: &str) -> String { + url.split('/').take(3).collect::>().join("/").to_ascii_lowercase() +} + +impl ErpnextConfigInput { + /// Merges the form values with what is stored: trims, normalises the address, and decides which + /// secret applies. The stored secret is never sent to a different server than it was saved for. + pub fn resolve(self, stored: &ErpnextConfig) -> Result { + let t = |s: &str| s.trim().to_string(); + let raw_url = self.base_url.trim(); + let base_url = if raw_url.is_empty() { + String::new() + } else { + normalize_base_url(raw_url).map_err(|e| e.to_string())? + }; + let typed_secret = self.api_secret.trim(); + let api_secret = if self.clear_secret { + Secret::default() + } else if !typed_secret.is_empty() { + Secret::new(typed_secret) + } else { + if stored.api_secret.is_set() + && !stored.base_url.is_empty() + && !base_url.is_empty() + && origin_of(&stored.base_url) != origin_of(&base_url) + { + return Err("The server address changed: enter the API secret again.".into()); + } + stored.api_secret.clone() + }; + let mut uom_map = default_uom_map(); + for (unit, uom) in &self.uom_map { + if !uom.trim().is_empty() { + uom_map.insert(unit.trim().to_string(), uom.trim().to_string()); + } + } + let last_detect_result = match &self.last_detect_result { + Some(Value::Null) => String::new(), + Some(v) => v.to_string(), + None => stored.last_detect_result.clone(), + }; + Ok(ErpnextConfig { + base_url, + api_key: t(&self.api_key), + api_secret, + extra_ca_pem: self.extra_ca_pem.trim().to_string(), + company: t(&self.company), + company_address: t(&self.company_address), + naming_mode: NamingMode::parse(self.naming_mode.trim())?, + naming_series: t(&self.naming_series), + income_account: t(&self.income_account), + cost_center: t(&self.cost_center), + cgst_account: t(&self.cgst_account), + sgst_account: t(&self.sgst_account), + utgst_account: t(&self.utgst_account), + igst_account: t(&self.igst_account), + tax_template_intra: t(&self.tax_template_intra), + tax_template_inter: t(&self.tax_template_inter), + payment_bank_account: t(&self.payment_bank_account), + tds_account: t(&self.tds_account), + default_item_code: t(&self.default_item_code), + uom_map, + customer_group: t(&self.customer_group), + territory: t(&self.territory), + selling_price_list: t(&self.selling_price_list), + submit_on_push: self.submit_on_push, + attach_pdf: self.attach_pdf, + auto_push_on_issue: self.auto_push_on_issue, + create_missing_customers: self.create_missing_customers, + last_detect_result, + }) + } +} + +const SELECT: &str = "SELECT base_url, api_key, api_secret, extra_ca_pem, company, company_address, naming_mode, + naming_series, income_account, cost_center, cgst_account, sgst_account, utgst_account, igst_account, + tax_template_intra, tax_template_inter, payment_bank_account, tds_account, default_item_code, uom_map, + customer_group, territory, selling_price_list, submit_on_push, attach_pdf, auto_push_on_issue, + create_missing_customers, last_detect_result FROM erpnext_config WHERE id = 1"; + +pub fn load(conn: &Connection) -> Result { + conn.query_row(SELECT, [], |r| { + let naming: String = r.get(6)?; + let uom_json: String = r.get(19)?; + let mut uom_map = default_uom_map(); + if let Ok(stored) = serde_json::from_str::>(&uom_json) { + uom_map.extend(stored.into_iter().filter(|(_, v)| !v.trim().is_empty())); + } + Ok(ErpnextConfig { + base_url: r.get(0)?, + api_key: r.get(1)?, + api_secret: Secret::new(r.get::<_, String>(2)?), + extra_ca_pem: r.get(3)?, + company: r.get(4)?, + company_address: r.get(5)?, + naming_mode: NamingMode::parse(&naming).unwrap_or(NamingMode::Mirror), + naming_series: r.get(7)?, + income_account: r.get(8)?, + cost_center: r.get(9)?, + cgst_account: r.get(10)?, + sgst_account: r.get(11)?, + utgst_account: r.get(12)?, + igst_account: r.get(13)?, + tax_template_intra: r.get(14)?, + tax_template_inter: r.get(15)?, + payment_bank_account: r.get(16)?, + tds_account: r.get(17)?, + default_item_code: r.get(18)?, + uom_map, + customer_group: r.get(20)?, + territory: r.get(21)?, + selling_price_list: r.get(22)?, + submit_on_push: r.get::<_, i64>(23)? != 0, + attach_pdf: r.get::<_, i64>(24)? != 0, + auto_push_on_issue: r.get::<_, i64>(25)? != 0, + create_missing_customers: r.get::<_, i64>(26)? != 0, + last_detect_result: r.get(27)?, + }) + }) + .map_err(|e| e.to_string()) +} + +pub fn save(conn: &Connection, c: &ErpnextConfig) -> Result<(), String> { + let uom_json = serde_json::to_string(&c.uom_map).map_err(|e| e.to_string())?; + conn.execute( + "UPDATE erpnext_config SET base_url = ?1, api_key = ?2, api_secret = ?3, extra_ca_pem = ?4, + company = ?5, company_address = ?6, naming_mode = ?7, naming_series = ?8, income_account = ?9, + cost_center = ?10, cgst_account = ?11, sgst_account = ?12, utgst_account = ?13, igst_account = ?14, + tax_template_intra = ?15, tax_template_inter = ?16, payment_bank_account = ?17, tds_account = ?18, + default_item_code = ?19, uom_map = ?20, customer_group = ?21, territory = ?22, + selling_price_list = ?23, submit_on_push = ?24, attach_pdf = ?25, auto_push_on_issue = ?26, + create_missing_customers = ?27, last_detect_result = ?28, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = 1", + params![ + c.base_url, + c.api_key, + c.api_secret.expose(), + c.extra_ca_pem, + c.company, + c.company_address, + c.naming_mode.as_str(), + c.naming_series, + c.income_account, + c.cost_center, + c.cgst_account, + c.sgst_account, + c.utgst_account, + c.igst_account, + c.tax_template_intra, + c.tax_template_inter, + c.payment_bank_account, + c.tds_account, + c.default_item_code, + uom_json, + c.customer_group, + c.territory, + c.selling_price_list, + c.submit_on_push as i64, + c.attach_pdf as i64, + c.auto_push_on_issue as i64, + c.create_missing_customers as i64, + c.last_detect_result, + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +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()); + serde_json::from_value(base).unwrap() + } + + fn db() -> Connection { + crate::db::open_in_memory().unwrap() + } + + #[test] + fn debug_never_prints_the_secret() { + 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")); + } + + #[test] + 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_eq!(cfg.uom_for("hour"), "Hour"); + assert_eq!(cfg.uom_for("session"), "Nos"); + assert!(!cfg.api_secret.is_set()); + } + + #[test] + fn round_trip_keeps_secret_server_side_only() { + let conn = db(); + let stored = load(&conn).unwrap(); + let cfg = input(serde_json::json!({ "apiSecret": "topsecret", "company": "Arun Co", "submitOnPush": true })) + .resolve(&stored) + .unwrap(); + save(&conn, &cfg).unwrap(); + + let back = load(&conn).unwrap(); + assert_eq!(back.api_secret.expose(), "topsecret"); + assert_eq!(back.base_url, "https://erp.example.com"); + assert_eq!(back.company, "Arun Co"); + assert!(back.submit_on_push); + + let view = serde_json::to_value(back.view()).unwrap(); + assert_eq!(view["apiSecretSet"], true); + assert!(!view.to_string().contains("topsecret")); + assert!(view.get("apiSecret").is_none()); + } + + #[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(); + save(&conn, &first).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(); + assert_eq!(replace.api_secret.expose(), "two"); + 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(); + save(&conn, &first).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()); + 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()); + } + + #[test] + fn uom_map_merges_over_defaults() { + let cfg = input(serde_json::json!({ "uomMap": { "hour": "Hr", "unit": "" } })) + .resolve(&ErpnextConfig::default()) + .unwrap(); + assert_eq!(cfg.uom_for("hour"), "Hr"); + assert_eq!(cfg.uom_for("unit"), "Nos"); + assert_eq!(cfg.uom_for("minute"), "Minute"); + assert_eq!(cfg.uom_for("unheard-of"), "Nos"); + } +} diff --git a/src-tauri/src/integrations/erpnext/discovery.rs b/src-tauri/src/integrations/erpnext/discovery.rs new file mode 100644 index 0000000..6a7228a --- /dev/null +++ b/src-tauri/src/integrations/erpnext/discovery.rs @@ -0,0 +1,840 @@ +//! What the connected site is (versions, India Compliance), the lists the settings dropdowns need, and the +//! connection test that turns all of it into warnings. + +use super::client::ErpClient; +use super::config::{ErpnextConfig, NamingMode}; +use super::errors::{ErpError, ErrorKind}; +use serde::Serialize; +use serde_json::{json, Value}; + +// ---- versions and feature switches ---- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct Version { + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +impl Version { + /// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored. + pub fn parse(s: &str) -> Option { + let s = s.trim().trim_start_matches(['v', 'V']); + let numeric: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect(); + let mut parts = numeric.split('.').filter(|p| !p.is_empty()); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + Some(Version { major, minor, patch }) + } +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerVersions { + pub frappe: Option, + pub erpnext: Option, + pub india_compliance: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FeatureSwitches { + /// API v2 keeps a posted document name verbatim (Frappe >= 15.73, or 16+). + pub v2_naming: bool, + /// India Compliance is installed, so the GST header and item fields apply. + pub india_compliance: bool, +} + +pub fn feature_switches(v: &ServerVersions) -> FeatureSwitches { + let frappe = v.frappe.as_deref().and_then(Version::parse); + FeatureSwitches { + v2_naming: frappe.is_some_and(|f| (f.major, f.minor) >= (15, 73)), + india_compliance: v.india_compliance.is_some(), + } +} + +/// Reads `{"message": {"frappe": {"version": "15.73.0"}, ...}}`. +pub fn parse_versions(value: &Value) -> ServerVersions { + let apps = value.get("message").unwrap_or(value); + let version_of = |app: &str| -> Option { + let entry = apps.get(app)?; + let v = entry.get("version").and_then(Value::as_str).or_else(|| entry.as_str())?; + let v = v.trim(); + (!v.is_empty()).then(|| v.to_string()) + }; + ServerVersions { + frappe: version_of("frappe"), + erpnext: version_of("erpnext"), + india_compliance: version_of("india_compliance"), + } +} + +// ---- number rules (India Compliance) ---- + +pub const IC_MAX_NUMBER_LEN: usize = 16; + +/// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`. +pub fn ic_number_ok(number: &str) -> bool { + let mut chars = number.chars(); + let Some(first) = chars.next() else { return false }; + first.is_alphanumeric() + && number.chars().count() <= IC_MAX_NUMBER_LEN + && chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/') +} + +/// Length of a number generated from a Frappe naming series such as `SINV-.YY.-.#####`. A series with no +/// `#` run gets Frappe's default five digits. +pub fn naming_series_expanded_len(series: &str) -> usize { + let mut len = 0; + let mut has_digits = false; + for part in series.split('.') { + if part.is_empty() { + continue; + } + if part.chars().all(|c| c == '#') { + len += part.len(); + has_digits = true; + continue; + } + len += match part { + "YY" | "MM" | "DD" => 2, + "YYYY" => 4, + "FY" => 7, + literal => literal.chars().count(), + }; + } + if !has_digits { + len += 5; + } + len +} + +// ---- options for the settings dropdowns ---- + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OptionItem { + /// The value to store (the document name). + pub name: String, + pub label: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OptionError { + pub list: String, + pub message: String, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ErpnextOptions { + pub companies: Vec, + pub company_addresses: Vec, + pub income_accounts: Vec, + pub tax_accounts: Vec, + pub tax_templates: Vec, + pub naming_series: Vec, + pub item_groups: Vec, + pub uoms: Vec, + pub cost_centers: Vec, + pub price_lists: Vec, + pub customer_groups: Vec, + pub territories: Vec, + /// Lists that could not be loaded (one failing list does not hide the rest). + pub errors: Vec, +} + +fn text<'a>(row: &'a Value, key: &str) -> &'a str { + row.get(key).and_then(Value::as_str).unwrap_or("").trim() +} + +fn item(row: &Value, label_key: &str, detail: String) -> Option { + let name = text(row, "name"); + if name.is_empty() { + return None; + } + let label = text(row, label_key); + Some(OptionItem { + name: name.to_string(), + label: if label.is_empty() { name.to_string() } else { label.to_string() }, + detail, + }) +} + +fn join(parts: &[&str]) -> String { + parts.iter().filter(|p| !p.is_empty()).cloned().collect::>().join(" · ") +} + +/// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank. +pub fn parse_naming_series(doctype_response: &Value) -> Vec { + let docs = doctype_response + .get("docs") + .or_else(|| doctype_response.get("message").and_then(|m| m.get("docs"))) + .and_then(Value::as_array); + let Some(doc) = docs.and_then(|d| d.first()) else { return Vec::new() }; + let Some(fields) = doc.get("fields").and_then(Value::as_array) else { return Vec::new() }; + let Some(field) = fields.iter().find(|f| text(f, "fieldname") == "naming_series") else { + return Vec::new(); + }; + text(field, "options") + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(|l| OptionItem { name: l.to_string(), label: l.to_string(), detail: format!("e.g. {} digits", naming_series_expanded_len(l)) }) + .collect() +} + +impl ErpClient { + pub async fn get_logged_user(&self) -> Result { + let v = self.get(&["api", "method", "frappe.auth.get_logged_user"], &[]).await?; + let user = v.get("message").and_then(Value::as_str).unwrap_or("").trim().to_string(); + if user.is_empty() || user.eq_ignore_ascii_case("guest") { + return Err(ErpError { + kind: ErrorKind::Auth, + status: None, + message: "The API key and secret were not accepted (the site treated the request as Guest).".into(), + exc_type: None, + }); + } + Ok(user) + } + + pub async fn get_versions(&self) -> Result { + let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?; + Ok(parse_versions(&v)) + } + + /// `GET /api/resource/` with every row (`limit_page_length=0`). + pub async fn list_resource( + &self, + doctype: &str, + fields: &[&str], + filters: Value, + order_by: &str, + ) -> Result, ErpError> { + let mut query = vec![ + ("fields", json!(fields).to_string()), + ("limit_page_length", "0".to_string()), + ("order_by", order_by.to_string()), + ]; + if !filters.is_null() { + query.push(("filters", filters.to_string())); + } + let v = self.get(&["api", "resource", doctype], &query).await?; + Ok(v.get("data").and_then(Value::as_array).cloned().unwrap_or_default()) + } + + pub async fn load_options(&self, company: &str) -> Result { + let mut out = ErpnextOptions::default(); + let company = company.trim(); + + macro_rules! load { + ($list:literal, $target:expr, $call:expr) => { + match $call.await { + Ok(rows) => $target = rows, + Err(e) if matches!(e.kind, ErrorKind::Auth | ErrorKind::Network | ErrorKind::Timeout | ErrorKind::Config | ErrorKind::Protocol) => { + return Err(e) + } + Err(e) => out.errors.push(OptionError { list: $list.to_string(), message: e.to_string() }), + } + }; + } + + load!("companies", out.companies, async { + self.list_resource("Company", &["name", "company_name", "abbr", "country", "default_currency"], Value::Null, "name asc") + .await + .map(|rows| { + rows.iter() + .filter_map(|r| item(r, "company_name", join(&[text(r, "abbr"), text(r, "default_currency")]))) + .collect() + }) + }); + load!("itemGroups", out.item_groups, async { + self.list_resource("Item Group", &["name"], Value::Null, "name asc") + .await + .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) + }); + load!("uoms", out.uoms, async { + self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc") + .await + .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) + }); + load!("priceLists", out.price_lists, async { + self.list_resource("Price List", &["name"], json!([["enabled", "=", 1], ["selling", "=", 1]]), "name asc") + .await + .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) + }); + load!("customerGroups", out.customer_groups, async { + self.list_resource("Customer Group", &["name"], json!([["is_group", "=", 0]]), "name asc") + .await + .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) + }); + load!("territories", out.territories, async { + self.list_resource("Territory", &["name"], json!([["is_group", "=", 0]]), "name asc") + .await + .map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect()) + }); + load!("namingSeries", out.naming_series, async { + self.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Sales Invoice".to_string())]) + .await + .map(|v| parse_naming_series(&v)) + }); + + if company.is_empty() { + return Ok(out); + } + load!("companyAddresses", out.company_addresses, async { + self.list_resource( + "Address", + &["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"], + json!([ + ["Dynamic Link", "link_doctype", "=", "Company"], + ["Dynamic Link", "link_name", "=", company], + ["disabled", "=", 0] + ]), + "name asc", + ) + .await + .map(|rows| { + rows.iter() + .filter_map(|r| { + item( + r, + "address_title", + join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]), + ) + }) + .collect() + }) + }); + load!("incomeAccounts", out.income_accounts, async { + self.list_resource( + "Account", + &["name", "account_name", "account_type"], + json!([["company", "=", company], ["root_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]), + "name asc", + ) + .await + .map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect()) + }); + load!("taxAccounts", out.tax_accounts, async { + self.list_resource( + "Account", + &["name", "account_name", "account_type"], + json!([["company", "=", company], ["account_type", "=", "Tax"], ["is_group", "=", 0], ["disabled", "=", 0]]), + "name asc", + ) + .await + .map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect()) + }); + load!("taxTemplates", out.tax_templates, async { + self.list_resource( + "Sales Taxes and Charges Template", + &["name", "title", "is_default"], + json!([["company", "=", company], ["disabled", "=", 0]]), + "name asc", + ) + .await + .map(|rows| rows.iter().filter_map(|r| item(r, "title", String::new())).collect()) + }); + load!("costCenters", out.cost_centers, async { + self.list_resource( + "Cost Center", + &["name", "cost_center_name"], + json!([["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]), + "name asc", + ) + .await + .map(|rows| rows.iter().filter_map(|r| item(r, "cost_center_name", String::new())).collect()) + }); + Ok(out) + } +} + +// ---- connection test and warnings ---- + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct Warning { + pub code: String, + pub message: String, +} + +fn warn(code: &str, message: impl Into) -> Warning { + Warning { code: code.to_string(), message: message.into() } +} + +/// Facts from the local database that the warnings need. +#[derive(Debug, Clone, Default)] +pub struct LocalFacts { + pub vendor_registered: bool, + /// Numbers of issued/cancelled invoices that India Compliance would refuse (at most a few examples). + pub invalid_numbers: Vec, + /// The number the next invoice would get, if a series is active. + pub next_number: Option, +} + +/// What was learned from the site. `None` means the check could not run. +#[derive(Debug, Clone, Default)] +pub struct RemoteFacts { + pub versions: Option, + pub company_address_count: Option, + /// Rows of GST Settings -> gst_accounts, or the reason they could not be read. + pub gst_accounts: Option, String>>, +} + +/// Checks that the configured tax accounts appear as Output accounts for the company in GST Settings. +/// Otherwise India Compliance silently files the items as Nil-Rated. +pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec { + let for_company: Vec<&Value> = rows + .iter() + .filter(|r| text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output")) + .collect(); + if for_company.is_empty() { + return vec![warn( + "gst_accounts_not_configured", + format!( + "GST accounts are not configured for \"{}\" in GST Settings. India Compliance will file these invoices as Nil-Rated.", + cfg.company + ), + )]; + } + let has = |field: &str, account: &str| for_company.iter().any(|r| text(r, field) == account); + let mut out = Vec::new(); + for (field, label, account) in [ + ("cgst_account", "CGST", cfg.cgst_account.as_str()), + ("sgst_account", "SGST", cfg.sgst_account.as_str()), + ("igst_account", "IGST", cfg.igst_account.as_str()), + ] { + if !account.is_empty() && !has(field, account) { + out.push(warn( + "gst_account_mismatch", + format!("The {label} account \"{account}\" is not the {label} output account in GST Settings; GST returns would not pick the tax up."), + )); + } + } + out +} + +/// Pure: turns the configuration and the facts gathered into the list shown after a connection test. +pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFacts) -> Vec { + let mut w = Vec::new(); + let features = remote.versions.as_ref().map(feature_switches); + let ic = features.is_some_and(|f| f.india_compliance); + + if remote.versions.is_none() { + w.push(warn( + "versions_unknown", + "The site's versions could not be read, so India Compliance and API v2 naming were not detected.", + )); + } + if cfg.company.is_empty() { + w.push(warn("company_missing", "No company is selected.")); + } + if cfg.income_account.is_empty() { + w.push(warn( + "income_account_missing", + "No income account is set. Rows without an item code need one, and ERPNext has no fallback for them.", + )); + } + if cfg.naming_mode == NamingMode::Mirror { + if let Some(f) = features { + if !f.v2_naming { + w.push(warn( + "mirror_unsupported", + "Mirroring the Voiced number needs Frappe 15.73 or newer. Use the ERPNext series naming mode instead.", + )); + } + } + } else if cfg.naming_series.is_empty() { + w.push(warn("naming_series_missing", "ERPNext series naming is selected but no naming series is chosen.")); + } + + if local.vendor_registered { + if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty() { + w.push(warn( + "tax_accounts_missing", + "The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.", + )); + } + if remote.versions.is_some() && !ic { + w.push(warn( + "ic_not_detected", + "India Compliance is not installed on the site, so the GST fields (place of supply, HSN, GSTIN) will not be sent.", + )); + } + } + + if ic { + if local.vendor_registered { + match &remote.gst_accounts { + Some(Ok(rows)) => w.extend(check_gst_accounts(rows, cfg)), + Some(Err(why)) => w.push(warn( + "gst_settings_unreadable", + format!("GST Settings could not be read ({why}), so the GST accounts were not checked."), + )), + None => {} + } + } + if cfg.company_address.is_empty() { + w.push(warn("company_address_missing", "India Compliance needs a company address on every invoice; none is selected.")); + } else if remote.company_address_count == Some(0) { + w.push(warn("company_address_missing", "No address is linked to the company on the site.")); + } + match cfg.naming_mode { + NamingMode::Mirror => { + if !local.invalid_numbers.is_empty() { + w.push(warn( + "number_invalid", + format!( + "India Compliance allows at most {IC_MAX_NUMBER_LEN} characters (letters, digits, - and /). These numbers do not qualify: {}.", + local.invalid_numbers.join(", ") + ), + )); + } + if let Some(next) = &local.next_number { + if !ic_number_ok(next) { + w.push(warn( + "number_invalid", + format!("The next invoice number {next} is longer than {IC_MAX_NUMBER_LEN} characters or has characters India Compliance refuses."), + )); + } + } + } + NamingMode::Series => { + let len = naming_series_expanded_len(&cfg.naming_series); + if !cfg.naming_series.is_empty() && len > IC_MAX_NUMBER_LEN { + w.push(warn( + "naming_series_too_long", + format!( + "The naming series \"{}\" produces numbers of about {len} characters; India Compliance allows {IC_MAX_NUMBER_LEN}.", + cfg.naming_series + ), + )); + } + } + } + } + w +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionTest { + pub user: String, + pub versions: ServerVersions, + pub features: FeatureSwitches, + /// Same as `features.india_compliance`, for the UI. + pub india_compliance: bool, + pub warnings: Vec, +} + +/// 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 { + let user = client.get_logged_user().await?; + let versions = match client.get_versions().await { + Ok(v) => Some(v), + Err(e) if e.kind == ErrorKind::Auth => return Err(e), + Err(_) => None, + }; + let mut remote = RemoteFacts { versions, ..Default::default() }; + let features = remote.versions.as_ref().map(feature_switches); + if features.is_some_and(|f| f.india_compliance) { + if !cfg.company.is_empty() { + remote.company_address_count = client + .list_resource( + "Address", + &["name"], + json!([["Dynamic Link", "link_doctype", "=", "Company"], ["Dynamic Link", "link_name", "=", cfg.company], ["disabled", "=", 0]]), + "name asc", + ) + .await + .ok() + .map(|rows| rows.len()); + } + if local.vendor_registered { + remote.gst_accounts = Some( + client + .get(&["api", "resource", "GST Settings", "GST Settings"], &[]) + .await + .map(|v| v.get("data").and_then(|d| d.get("gst_accounts")).and_then(Value::as_array).cloned().unwrap_or_default()) + .map_err(|e| e.to_string()), + ); + } + } + let warnings = build_warnings(cfg, local, &remote); + let versions = remote.versions.unwrap_or_default(); + let features = feature_switches(&versions); + Ok(ConnectionTest { user, india_compliance: features.india_compliance, features, versions, warnings }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> ErpnextConfig { + ErpnextConfig { + company: "Arun Co".into(), + company_address: "Arun Co-Billing".into(), + income_account: "Sales - AC".into(), + cgst_account: "Output CGST - AC".into(), + sgst_account: "Output SGST - AC".into(), + igst_account: "Output IGST - AC".into(), + ..Default::default() + } + } + + fn ic_versions(frappe: &str) -> RemoteFacts { + RemoteFacts { + versions: Some(ServerVersions { + frappe: Some(frappe.into()), + erpnext: Some("15.121.6".into()), + india_compliance: Some("15.0.0".into()), + }), + company_address_count: Some(1), + gst_accounts: Some(Ok(vec![json!({ + "company": "Arun Co", "account_type": "Output", + "cgst_account": "Output CGST - AC", "sgst_account": "Output SGST - AC", "igst_account": "Output IGST - AC" + })])), + } + } + + fn codes(w: &[Warning]) -> Vec<&str> { + w.iter().map(|w| w.code.as_str()).collect() + } + + #[test] + fn versions_parse_leniently() { + assert_eq!(Version::parse("15.73.2"), Some(Version { major: 15, minor: 73, patch: 2 })); + assert_eq!(Version::parse("v16.0.0-dev"), Some(Version { major: 16, minor: 0, patch: 0 })); + assert_eq!(Version::parse("15.1"), Some(Version { major: 15, minor: 1, patch: 0 })); + assert_eq!(Version::parse("15.73.0-beta.2"), Some(Version { major: 15, minor: 73, patch: 0 })); + assert_eq!(Version::parse("unknown"), None); + } + + #[test] + fn feature_switches_follow_the_frappe_version_and_ic_presence() { + let v = |frappe: &str, ic: bool| ServerVersions { + frappe: Some(frappe.into()), + erpnext: None, + india_compliance: ic.then(|| "15.0.0".into()), + }; + assert!(!feature_switches(&v("15.72.9", false)).v2_naming); + assert!(feature_switches(&v("15.73.0", false)).v2_naming); + assert!(feature_switches(&v("16.0.0-dev", true)).v2_naming); + assert!(feature_switches(&v("16.0.0-dev", true)).india_compliance); + assert!(!feature_switches(&ServerVersions::default()).v2_naming); + } + + #[test] + fn get_versions_response_is_parsed() { + let v = parse_versions(&json!({ "message": { + "frappe": { "title": "Frappe Framework", "version": "15.73.0" }, + "erpnext": { "version": "15.121.6" }, + "india_compliance": { "version": "15.9.0" }, + "hrms": { "version": "15.0.0" }, + }})); + assert_eq!(v.frappe.as_deref(), Some("15.73.0")); + assert_eq!(v.erpnext.as_deref(), Some("15.121.6")); + assert_eq!(v.india_compliance.as_deref(), Some("15.9.0")); + assert!(parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } })).india_compliance.is_none()); + } + + #[test] + fn india_compliance_number_rule() { + for ok in ["AP/2026-001", "A", "1234567890123456", "AP-24-25/0001"] { + assert!(ic_number_ok(ok), "{ok}"); + } + for bad in ["", "12345678901234567", "_AB", "AB 01", "AB_01", "-AB", "/AB", "AB#1"] { + assert!(!ic_number_ok(bad), "{bad}"); + } + } + + #[test] + fn naming_series_is_expanded_before_measuring() { + assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5); + assert_eq!(naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"), 9 + 4 + 1 + 5); + assert_eq!(naming_series_expanded_len("INV-.MM.-.DD.-.####"), 4 + 2 + 1 + 2 + 1 + 4); + assert_eq!(naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"), 15 + 4 + 1 + 5); + } + + #[test] + fn naming_series_options_come_from_getdoctype() { + let resp = json!({ "docs": [{ "fields": [ + { "fieldname": "customer", "options": "Customer" }, + { "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" } + ]}]}); + let items = parse_naming_series(&resp); + assert_eq!(items.iter().map(|i| i.name.as_str()).collect::>(), ["SINV-.YY.-", "ACC-SINV-.YYYY.-"]); + assert!(parse_naming_series(&json!({})).is_empty()); + } + + #[test] + fn a_healthy_ic_setup_has_no_warnings() { + let local = LocalFacts { vendor_registered: true, invalid_numbers: vec![], next_number: Some("AP/2026-001".into()) }; + assert_eq!(build_warnings(&cfg(), &local, &ic_versions("15.73.0")), vec![]); + } + + #[test] + fn gst_accounts_not_configured_or_mismatched_are_flagged() { + let local = LocalFacts { vendor_registered: true, ..Default::default() }; + let mut remote = ic_versions("15.73.0"); + remote.gst_accounts = Some(Ok(vec![])); + assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_accounts_not_configured"]); + + let mut remote = ic_versions("15.73.0"); + remote.gst_accounts = Some(Ok(vec![json!({ "company": "Arun Co", "account_type": "Output", + "cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" })])); + let w = build_warnings(&cfg(), &local, &remote); + assert_eq!(codes(&w), ["gst_account_mismatch"]); + assert!(w[0].message.contains("SGST")); + + let mut remote = ic_versions("15.73.0"); + remote.gst_accounts = Some(Err("HTTP 403".into())); + assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_settings_unreadable"]); + } + + #[test] + fn long_numbers_and_series_are_flagged_under_ic_only() { + let local = LocalFacts { + vendor_registered: false, + invalid_numbers: vec!["INVOICE/2026/000001".into()], + next_number: Some("INVOICE/2026/000002".into()), + }; + let w = build_warnings(&cfg(), &local, &ic_versions("15.73.0")); + assert_eq!(codes(&w), ["number_invalid", "number_invalid"]); + // Plain ERPNext has no such rule. + let mut plain = ic_versions("15.73.0"); + plain.versions.as_mut().unwrap().india_compliance = None; + assert!(build_warnings(&cfg(), &local, &plain).is_empty()); + + let mut series = cfg(); + series.naming_mode = NamingMode::Series; + series.naming_series = "LONGPREFIX-ABC-.YYYY.-.#####".into(); + let w = build_warnings(&series, &LocalFacts::default(), &ic_versions("15.73.0")); + assert_eq!(codes(&w), ["naming_series_too_long"]); + } + + #[test] + fn missing_company_address_and_old_frappe_are_flagged() { + let mut c = cfg(); + c.company_address.clear(); + let w = build_warnings(&c, &LocalFacts::default(), &ic_versions("15.60.0")); + assert_eq!(codes(&w), ["mirror_unsupported", "company_address_missing"]); + + let mut remote = ic_versions("15.73.0"); + remote.company_address_count = Some(0); + assert_eq!(codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)), ["company_address_missing"]); + } + + #[test] + fn required_settings_and_unknown_versions_are_flagged() { + let w = build_warnings(&ErpnextConfig::default(), &LocalFacts { vendor_registered: true, ..Default::default() }, &RemoteFacts::default()); + assert_eq!(codes(&w), ["versions_unknown", "company_missing", "income_account_missing", "tax_accounts_missing"]); + } + + #[test] + fn registered_vendor_without_ic_gets_a_notice() { + let mut remote = ic_versions("15.73.0"); + remote.versions.as_mut().unwrap().india_compliance = None; + let w = build_warnings(&cfg(), &LocalFacts { vendor_registered: true, ..Default::default() }, &remote); + assert_eq!(codes(&w), ["ic_not_detected"]); + } + + // ---- against the mock server ---- + + use crate::integrations::erpnext::testutil::{client, reply, serve_fn, Reply}; + + fn site(line: &str) -> Reply { + let path = line.split_whitespace().nth(1).unwrap_or(""); + if path.contains("get_logged_user") { + reply(200, json!({ "message": "integration@example.com" })) + } else if path.contains("get_versions") { + reply(200, json!({ "message": { + "frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" } + }})) + } else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") { + reply(200, json!({ "data": { "gst_accounts": [] } })) + } else if path.starts_with("/api/resource/Address?") { + reply(200, json!({ "data": [ + { "name": "Arun Co-Billing", "address_title": "Arun Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" } + ]})) + } else if path.starts_with("/api/resource/Company?") { + reply(200, json!({ "data": [{ "name": "Arun Co", "company_name": "Arun Co", "abbr": "AC", "default_currency": "INR" }] })) + } else if path.starts_with("/api/resource/Account?") && path.contains("Income") { + reply(200, json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] })) + } else if path.starts_with("/api/resource/Account?") && path.contains("Tax") { + reply(200, json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] })) + } else if path.starts_with("/api/resource/Price%20List?") { + reply(403, json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" })) + } else if path.contains("getdoctype") { + reply(200, json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] })) + } else if path.starts_with("/api/resource/UOM?") { + reply(200, json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] })) + } else { + reply(200, json!({ "data": [] })) + } + } + + #[tokio::test] + async fn connection_test_reports_user_versions_ic_and_warnings() { + let mock = serve_fn(|_, line| site(line)).await; + let local = LocalFacts { vendor_registered: true, next_number: Some("AP/2026-001".into()), ..Default::default() }; + let result = test_connection(&client(&mock), &cfg(), &local).await.unwrap(); + assert_eq!(result.user, "integration@example.com"); + assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0")); + assert!(result.india_compliance && result.features.v2_naming); + assert_eq!(codes(&result.warnings), ["gst_accounts_not_configured"]); + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["indiaCompliance"], true); + assert_eq!(json["features"]["v2Naming"], true); + } + + #[tokio::test] + async fn connection_test_fails_for_the_guest_user_and_bad_credentials() { + let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await; + let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err(); + assert_eq!(err.kind, ErrorKind::Auth); + + let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await; + let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err(); + assert_eq!(err.kind, ErrorKind::Auth); + assert!(!err.to_string().contains("thesecret")); + } + + #[tokio::test] + async fn options_load_per_list_and_one_failure_does_not_hide_the_rest() { + let mock = serve_fn(|_, line| site(line)).await; + let options = client(&mock).load_options("Arun Co").await.unwrap(); + assert_eq!(options.companies.len(), 1); + assert_eq!(options.companies[0].name, "Arun Co"); + assert_eq!(options.companies[0].detail, "AC · INR"); + assert_eq!(options.company_addresses[0].name, "Arun Co-Billing"); + assert!(options.company_addresses[0].detail.contains("Mumbai")); + assert_eq!(options.income_accounts[0].name, "Sales - AC"); + assert_eq!(options.tax_accounts[0].name, "Output CGST - AC"); + assert_eq!(options.naming_series[0].name, "SINV-.YY.-"); + assert_eq!(options.uoms.iter().map(|u| u.name.as_str()).collect::>(), ["Nos", "Hour"]); + assert!(options.price_lists.is_empty()); + assert_eq!(options.errors.len(), 1); + assert_eq!(options.errors[0].list, "priceLists"); + assert!(options.errors[0].message.contains("No permission for Price List")); + + // Company-scoped lists are skipped until a company is chosen. + let none = client(&mock).load_options(" ").await.unwrap(); + assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty()); + let requests = mock.requests.lock().unwrap().join("\n"); + assert!(requests.contains("root_type"), "income accounts are filtered by root type"); + assert!(requests.contains("limit_page_length=0")); + } + + #[tokio::test] + async fn options_abort_on_authentication_failure() { + let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await; + let err = client(&mock).load_options("Arun Co").await.unwrap_err(); + assert_eq!(err.kind, ErrorKind::Auth); + assert_eq!(mock.hits.load(std::sync::atomic::Ordering::SeqCst), 1, "stops at the first auth failure"); + } +} diff --git a/src-tauri/src/integrations/erpnext/errors.rs b/src-tauri/src/integrations/erpnext/errors.rs new file mode 100644 index 0000000..1949fbd --- /dev/null +++ b/src-tauri/src/integrations/erpnext/errors.rs @@ -0,0 +1,418 @@ +//! Turning Frappe/ERPNext error responses into one readable message plus a machine-usable kind. + +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorKind { + /// 401: wrong key/secret. + Auth, + /// 403: the user lacks a role or permission. + Permission, + NotFound, + /// 409 (or a `DuplicateEntryError`): the document already exists. + Duplicate, + /// 417 and other 4xx data rejections. + Validation, + RateLimit, + Server, + Timeout, + Network, + /// A setting is missing or invalid; nothing was sent. + Config, + /// The server answered, but not like a Frappe site. + Protocol, + Other, +} + +impl ErrorKind { + fn label(self) -> &'static str { + match self { + ErrorKind::Auth => "Authentication failed", + ErrorKind::Permission => "Permission denied", + ErrorKind::NotFound => "Not found", + ErrorKind::Duplicate => "Already exists", + ErrorKind::Validation => "ERPNext rejected the data", + ErrorKind::RateLimit => "Rate limited", + ErrorKind::Server => "ERPNext server error", + ErrorKind::Timeout | ErrorKind::Network | ErrorKind::Config | ErrorKind::Protocol => "", + ErrorKind::Other => "Request failed", + } + } +} + +#[derive(Debug, Clone)] +pub struct ErpError { + pub kind: ErrorKind, + pub status: Option, + pub message: String, + /// Frappe exception class (e.g. `DuplicateEntryError`) when the server named one. + pub exc_type: Option, +} + +impl std::fmt::Display for ErpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let label = self.kind.label(); + match (label.is_empty(), self.status) { + (true, _) => f.write_str(&self.message), + (false, Some(status)) => write!(f, "{label} (HTTP {status}): {}", self.message), + (false, None) => write!(f, "{label}: {}", self.message), + } + } +} + +impl std::error::Error for ErpError {} + +impl From for String { + fn from(e: ErpError) -> String { + e.to_string() + } +} + +impl ErpError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + ErpError { kind, status: None, message: message.into(), exc_type: None } + } + + pub fn config(message: impl Into) -> Self { + Self::new(ErrorKind::Config, message) + } + + pub fn protocol(message: impl Into) -> Self { + Self::new(ErrorKind::Protocol, message) + } + + /// Builds the error for a non-2xx response. + pub fn from_response(status: u16, body: &str) -> Self { + let parsed = parse_error_body(body); + let mut kind = kind_for_status(status); + 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 } + } + + /// Removes every occurrence of the given secrets from the message. + pub fn redacted(mut self, secrets: &[&str]) -> Self { + for s in secrets.iter().filter(|s| s.len() >= 4) { + self.message = self.message.replace(s, "[redacted]"); + } + self + } +} + +pub fn kind_for_status(status: u16) -> ErrorKind { + match status { + 401 => ErrorKind::Auth, + 403 => ErrorKind::Permission, + 404 => ErrorKind::NotFound, + 409 => ErrorKind::Duplicate, + 429 => ErrorKind::RateLimit, + 400 | 417 | 422 => ErrorKind::Validation, + 500..=599 => ErrorKind::Server, + _ => ErrorKind::Other, + } +} + +fn default_status_text(status: u16) -> String { + let reason = reqwest::StatusCode::from_u16(status) + .ok() + .and_then(|s| s.canonical_reason()) + .unwrap_or("unexpected response"); + format!("HTTP {status} {reason}") +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ParsedError { + pub message: Option, + pub exc_type: Option, +} + +const MAX_MESSAGE_CHARS: usize = 600; + +/// Extracts the human message from a Frappe error body. +/// +/// v2 puts `errors[]` first. v1 uses `_server_messages` (a JSON string holding a list of JSON strings, +/// each usually an object with an HTML `message`), then `_error_message`, then `exception` (with the +/// `frappe.exceptions.X: ` prefix dropped), then `exc_type`. A body that is not JSON (an nginx 502 page) +/// yields its stripped text, or nothing when that is empty. +pub fn parse_error_body(body: &str) -> ParsedError { + let Ok(json) = serde_json::from_str::(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 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 messages: Vec = Vec::new(); + + if let Some(errors) = obj.get("errors").and_then(Value::as_array) { + for e in errors { + 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); + if exc_type.is_none() { + exc_type = o + .get("type") + .and_then(Value::as_str) + .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())) { + push_unique(&mut messages, m); + } + } + _ => {} + } + } + } + + if messages.is_empty() { + if let Some(raw) = obj.get("_server_messages").and_then(Value::as_str) { + for m in server_messages(raw) { + push_unique(&mut messages, m); + } + } + } + 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()) { + messages.push(m); + } + } + if messages.is_empty() { + if let Some(raw) = obj.get("exception").and_then(Value::as_str) { + let (ty, msg) = split_exception(raw); + if exc_type.is_none() { + exc_type = ty; + } + if !msg.is_empty() { + messages.push(msg); + } + } + } + if messages.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); + } + } + if messages.is_empty() { + if let Some(t) = &exc_type { + messages.push(t.clone()); + } + } + 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); + } + + ParsedError { message: non_empty(truncate(&messages.join("; "))), exc_type } +} + +fn server_messages(raw: &str) -> Vec { + let Ok(Value::Array(items)) = serde_json::from_str::(raw) else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let text = match item { + Value::String(s) => match serde_json::from_str::(s) { + 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(), + _ => String::new(), + }; + let text = strip_html(&text); + (!text.is_empty()).then_some(text) + }) + .collect() +} + +/// Splits `frappe.exceptions.ValidationError: Row 1: bad` into (`ValidationError`, `Row 1: bad`). +/// A string without a dotted-identifier prefix is returned whole. +fn split_exception(raw: &str) -> (Option, 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 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.contains('.') + { + // `frappe.exceptions.DoesNotExistError` with no message. + return (first_line.rsplit('.').next().map(str::to_string), String::new()); + } + (None, strip_html(first_line)) +} + +fn push_unique(list: &mut Vec, text: String) { + if !text.is_empty() && !list.contains(&text) { + list.push(text); + } +} + +fn non_empty(s: String) -> Option { + (!s.is_empty()).then_some(s) +} + +fn truncate(s: &str) -> String { + if s.chars().count() <= MAX_MESSAGE_CHARS { + return s.to_string(); + } + let cut: String = s.chars().take(MAX_MESSAGE_CHARS).collect(); + format!("{cut}...") +} + +/// Drops tags, decodes the common entities and collapses whitespace. +pub fn strip_html(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut in_tag = false; + let mut tag = String::new(); + for ch in input.chars() { + match ch { + '<' => { + in_tag = true; + tag.clear(); + } + '>' 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") { + out.push('\n'); + } + } + _ if in_tag => tag.push(ch), + _ => out.push(ch), + } + } + let decoded = out + .replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("&", "&"); + decoded + .lines() + .map(|l| l.split_whitespace().collect::>().join(" ")) + .filter(|l| !l.is_empty()) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn v1_server_messages_are_double_encoded_and_html_is_stripped() { + let inner1 = json!({ "message": "Row 1: Account Sales & more", "indicator": "red" }).to_string(); + let inner2 = json!({ "message": "Second
problem" }).to_string(); + let server_messages = serde_json::to_string(&vec![inner1, inner2]).unwrap(); + let body = json!({ + "exc_type": "ValidationError", + "exception": "frappe.exceptions.ValidationError: ignored because messages exist", + "_server_messages": server_messages, + }) + .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.exc_type.as_deref(), Some("ValidationError")); + let err = ErpError::from_response(417, &body); + assert_eq!(err.kind, ErrorKind::Validation); + assert_eq!(err.status, Some(417)); + } + + #[test] + fn v1_falls_back_to_error_message_then_exception_then_exc_type() { + let a = parse_error_body(&json!({ "_error_message": "Not permitted" }).to_string()); + assert_eq!(a.message.as_deref(), Some("Not permitted")); + + let b = parse_error_body( + &json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice AP/1 already exists" }).to_string(), + ); + assert_eq!(b.message.as_deref(), Some("Sales Invoice AP/1 already exists")); + assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError")); + + let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string()); + assert_eq!(c.message.as_deref(), Some("PermissionError")); + } + + #[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")); + assert_eq!(parsed.exc_type, None); + } + + #[test] + fn v2_errors_array_is_joined() { + let body = json!({ + "errors": [ + { "type": "ValidationError", "message": "Customer X is disabled", "exception": "frappe.exceptions.ValidationError: x" }, + { "type": "ValidationError", "exception": "frappe.exceptions.ValidationError: Row 2: rate missing" }, + ] + }) + .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.exc_type.as_deref(), Some("ValidationError")); + } + + #[test] + fn non_json_bodies_use_stripped_text_or_status() { + let html = "

502 Bad Gateway


nginx"; + let err = ErpError::from_response(502, html); + assert_eq!(err.kind, ErrorKind::Server); + assert_eq!(err.message, "502 Bad Gateway"); + + let empty = ErpError::from_response(503, ""); + assert_eq!(empty.message, "HTTP 503 Service Unavailable"); + } + + #[test] + fn statuses_map_to_kinds() { + for (status, kind) in [ + (401, ErrorKind::Auth), + (403, ErrorKind::Permission), + (404, ErrorKind::NotFound), + (409, ErrorKind::Duplicate), + (417, ErrorKind::Validation), + (429, ErrorKind::RateLimit), + (500, ErrorKind::Server), + (502, ErrorKind::Server), + (418, ErrorKind::Other), + ] { + 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()); + 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"]); + 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"); + } +} diff --git a/src-tauri/src/integrations/erpnext/mapping.rs b/src-tauri/src/integrations/erpnext/mapping.rs new file mode 100644 index 0000000..e6393a8 --- /dev/null +++ b/src-tauri/src/integrations/erpnext/mapping.rs @@ -0,0 +1,980 @@ +//! Pure builders from Voiced data to ERPNext request bodies. No I/O, no clock, no floats in the money +//! path: every amount is rebuilt in integer paise with the same `gst` functions that issued the invoice, +//! checked against what was stored, and only then written out as a decimal. + +use super::config::{ErpnextConfig, NamingMode}; +use crate::gst::{self, TaxType, Totals}; +use crate::models::{Client, Invoice, InvoiceItem}; +use serde_json::{json, Map, Number, Value}; +use std::collections::HashSet; + +pub const SALES_INVOICE_V1: &[&str] = &["api", "resource", "Sales Invoice"]; +pub const SALES_INVOICE_V2: &[&str] = &["api", "v2", "document", "Sales Invoice"]; +pub const CUSTOMER: &[&str] = &["api", "resource", "Customer"]; +pub const ADDRESS: &[&str] = &["api", "resource", "Address"]; + +/// Frappe's `item_name` column is a 140-character Data field. +const ITEM_NAME_MAX: usize = 140; + +/// Where to POST and what. `idempotent` says whether repeating the POST is safe (a mirrored name makes +/// a repeat a 409), which decides whether the client may retry after a timeout or 5xx. +#[derive(Debug, Clone, PartialEq)] +pub struct BuiltRequest { + pub path: &'static [&'static str], + pub body: Value, + pub idempotent: bool, +} + +/// Supplier facts as frozen on the invoice when it was issued. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Vendor { + pub registered: bool, + pub state_code: String, +} + +impl Vendor { + pub fn from_snapshot(snapshot: &str) -> Option { + let v: Value = serde_json::from_str(snapshot).ok()?; + Some(Vendor { + registered: v.get("gstRegistration")?.as_str()? != "unregistered", + state_code: v.get("vendorStateCode")?.as_str()?.to_string(), + }) + } +} + +pub struct InvoiceContext<'a> { + pub invoice: &'a Invoice, + pub config: &'a ErpnextConfig, + pub vendor: &'a Vendor, + /// Name of the Customer document on the site (after find-or-create). + pub customer: &'a str, + pub customer_address: Option<&'a str>, + /// ERPNext item code per invoice row, e.g. from a preset; `None` falls back to the default item code, + /// then to a code-less row. + pub item_codes: &'a [Option], + /// India Compliance is installed on the site. + pub india_compliance: bool, + /// Insert and submit in one POST (`docstatus: 1`); otherwise a draft lands for review. + pub submit: bool, +} + +// ---- money formatting ---- + +/// `123456` -> `"1234.56"`, exact. +pub fn paise_to_decimal(paise: i64) -> String { + let sign = if paise < 0 { "-" } else { "" }; + let abs = paise.unsigned_abs(); + format!("{sign}{}.{:02}", abs / 100, abs % 100) +} + +/// `scaled / 10^scale` as a trimmed decimal: `(1800, 3)` -> `"1.8"`, `(9000, 3)` -> `"9"`. +fn scaled_decimal(scaled: i64, scale: u32) -> String { + let div = 10u64.pow(scale); + let sign = if scaled < 0 { "-" } else { "" }; + let abs = scaled.unsigned_abs(); + let (whole, frac) = (abs / div, abs % div); + if frac == 0 { + return format!("{sign}{whole}"); + } + let frac = format!("{frac:0width$}", width = scale as usize); + format!("{sign}{whole}.{}", frac.trim_end_matches('0')) +} + +/// A JSON number built from an exact decimal string. The nearest double prints back as the same +/// shortest decimal, so nothing drifts. +fn decimal_number(decimal: &str) -> Value { + decimal + .parse::() + .ok() + .and_then(Number::from_f64) + .map(Value::Number) + .unwrap_or_else(|| Value::String(decimal.to_string())) +} + +fn money(paise: i64) -> Value { + decimal_number(&paise_to_decimal(paise)) +} + +fn div_round(num: i128, den: i128) -> i64 { + ((num + den / 2) / den) as i64 +} + +// ---- states, categories, units ---- + +/// India Compliance's `"29-Karnataka"` form, from the Voiced state code. +pub fn place_of_supply_label(code: &str) -> Result { + gst::STATES + .iter() + .find(|(c, _)| *c == code) + .map(|(c, name)| format!("{c}-{name}")) + .ok_or_else(|| format!("\"{code}\" is not a GST state code")) +} + +pub fn state_name(code: &str) -> Result<&'static str, String> { + gst::STATES + .iter() + .find(|(c, _)| *c == code) + .map(|(_, n)| *n) + .ok_or_else(|| format!("\"{code}\" is not a GST state code")) +} + +pub fn gst_category_label(category: &str) -> Result<&'static str, String> { + match category { + "registered_regular" => Ok("Registered Regular"), + "composition" => Ok("Registered Composition"), + "unregistered" => Ok("Unregistered"), + "sez" => Ok("SEZ"), + "overseas" => Ok("Overseas"), + other => Err(format!("Unknown GST category \"{other}\"")), + } +} + +fn parse_tax_type(s: &str) -> Result { + match s { + "none" => Ok(TaxType::None), + "cgst_sgst" => Ok(TaxType::CgstSgst), + "igst" => Ok(TaxType::Igst), + other => Err(format!("Unknown tax type \"{other}\"")), + } +} + +/// The text Voiced stores in `remarks` in series naming mode, used to find the invoice again. +pub fn remarks_marker(number: &str) -> String { + format!("Voiced invoice {number}") +} + +// ---- Sales Invoice ---- + +/// Recomputes the totals the way `issue_invoice` did and refuses to continue if they differ from what +/// was stored. This is the guarantee that ERPNext's grand total equals Voiced's. +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 stored = [ + (totals.cgst, inv.cgst_amount, "CGST"), + (totals.sgst, inv.sgst_amount, "SGST"), + (totals.igst, inv.igst_amount, "IGST"), + (totals.total, inv.total, "total"), + ]; + for (computed, stored, what) in stored { + if computed != gst::rupees_to_paise(stored) { + return Err(format!( + "Invoice {}: the recomputed {what} ({}) differs from the stored value ({}); it was not sent to ERPNext.", + inv.number, + paise_to_decimal(computed), + stored + )); + } + } + Ok((tax_type, rate_bp, totals)) +} + +/// Quantity in thousandths. ERPNext keeps three decimals on `qty`, so a finer quantity would change the amount. +fn qty_milli(item: &InvoiceItem, row: usize) -> Result { + let scaled = item.quantity * 1000.0; + let milli = scaled.round(); + if !scaled.is_finite() || (scaled - milli).abs() > 1e-6 || milli < 0.0 { + return Err(format!( + "Line {row}: quantity {} has more than 3 decimals, which ERPNext would round", + item.quantity + )); + } + Ok(milli as i64) +} + +fn first_line_and_rest(s: &str) -> (&str, &str) { + match s.split_once('\n') { + Some((head, tail)) => (head, tail), + None => (s, ""), + } +} + +/// Appends " (2)", " (3)" ... to the first line of repeated descriptions. ERPNext rejects repeated +/// rows ("Item None entered multiple times"), and the printed invoice is not affected. +fn distinct_descriptions(items: &[InvoiceItem]) -> Vec { + let mut used: HashSet = HashSet::new(); + items + .iter() + .enumerate() + .map(|(i, item)| { + let trimmed = item.description.trim(); + 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}") }; + n += 1; + } + used.insert(candidate.to_lowercase()); + candidate + }) + .collect() +} + +fn truncate_chars(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} + +fn set_if(map: &mut Map, key: &str, value: &str) { + if !value.trim().is_empty() { + map.insert(key.to_string(), json!(value.trim())); + } +} + +fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result, String> { + let cfg = ctx.config; + let inv = ctx.invoice; + if inv.items.is_empty() { + return Err(format!("Invoice {} has no lines.", inv.number)); + } + let descriptions = distinct_descriptions(&inv.items); + let mut rows = Vec::with_capacity(inv.items.len()); + for (i, item) in inv.items.iter().enumerate() { + let n = i + 1; + let milli = qty_milli(item, n)?; + let (rate_paise, qty_scaled) = if item.mode == "rate" { + (gst::rupees_to_paise(item.rate), milli) + } else { + (gst::line_amount_paise(item), 1000) + }; + // ERPNext computes amount = round(qty * rate); it has to land on the paise Voiced printed. + let erp_amount = div_round(rate_paise as i128 * qty_scaled as i128, 1000); + if erp_amount != gst::line_amount_paise(item) { + return Err(format!( + "Line {n}: ERPNext would compute {} but Voiced printed {}", + paise_to_decimal(erp_amount), + paise_to_decimal(gst::line_amount_paise(item)) + )); + } + + let code = ctx + .item_codes + .get(i) + .and_then(|c| c.as_deref()) + .map(str::trim) + .filter(|c| !c.is_empty()) + .or_else(|| Some(cfg.default_item_code.trim()).filter(|c| !c.is_empty())); + + let mut row = Map::new(); + match code { + Some(code) => { + row.insert("item_code".into(), json!(code)); + } + None => { + if cfg.income_account.trim().is_empty() { + return Err(format!( + "Line {n} has no ERPNext item code, so an income account is required in the ERPNext settings." + )); + } + let (head, _) = first_line_and_rest(&descriptions[i]); + row.insert("item_name".into(), json!(truncate_chars(head, ITEM_NAME_MAX))); + } + } + row.insert("description".into(), json!(descriptions[i])); + row.insert("qty".into(), decimal_number(&scaled_decimal(qty_scaled, 3))); + row.insert("rate".into(), money(rate_paise)); + row.insert("uom".into(), json!(cfg.uom_for(&item.unit))); + row.insert("conversion_factor".into(), json!(1)); + set_if(&mut row, "income_account", &cfg.income_account); + set_if(&mut row, "cost_center", &cfg.cost_center); + if apply_gst { + set_if(&mut row, "gst_hsn_code", &item.hsn_sac); + } + rows.push(Value::Object(row)); + } + Ok(rows) +} + +fn tax_row(account: &str, head: &str, rate: &str) -> Result { + if account.trim().is_empty() { + return Err(format!("The {head} account is not set in the ERPNext settings.")); + } + Ok(json!({ + "charge_type": "On Net Total", + "account_head": account.trim(), + "rate": decimal_number(rate), + "description": format!("{head} @ {rate}%"), + })) +} + +fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state: &str) -> Result, 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::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); + let second = gst::second_head_label(supplier_state); + let second_account = if second == "UTGST" && !cfg.utgst_account.trim().is_empty() { + &cfg.utgst_account + } else { + &cfg.sgst_account + }; + Ok(vec![tax_row(&cfg.cgst_account, "CGST", &half)?, tax_row(second_account, second, &half)?]) + } + } +} + +pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result { + 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)); + } + if ctx.customer.trim().is_empty() { + return Err("The ERPNext customer is not resolved.".into()); + } + if cfg.company.trim().is_empty() { + return Err("No ERPNext company is selected in the settings.".into()); + } + let (tax_type, rate_bp, totals) = verified_totals(inv)?; + let gst_fields = ctx.india_compliance && ctx.vendor.registered; + + let mut body = Map::new(); + match cfg.naming_mode { + NamingMode::Mirror => { + body.insert("name".into(), json!(inv.number)); + } + NamingMode::Series => { + if cfg.naming_series.trim().is_empty() { + return Err("ERPNext series naming is selected but no naming series is set.".into()); + } + body.insert("naming_series".into(), json!(cfg.naming_series.trim())); + } + } + body.insert("company".into(), json!(cfg.company.trim())); + body.insert("customer".into(), json!(ctx.customer.trim())); + body.insert("posting_date".into(), json!(inv.invoice_date)); + // Without this ERPNext rewrites posting_date to today. + body.insert("set_posting_time".into(), json!(1)); + if !inv.due_date.trim().is_empty() && inv.due_date >= inv.invoice_date { + body.insert("due_date".into(), json!(inv.due_date)); + } + set_if(&mut body, "po_no", &inv.po_number); + set_if(&mut body, "selling_price_list", &cfg.selling_price_list); + body.insert("ignore_pricing_rule".into(), json!(1)); + // So ERPNext's grand total is Voiced's total, not a rounded one. + body.insert("disable_rounded_total".into(), json!(1)); + + let remarks = match (cfg.naming_mode, inv.notes.trim()) { + (NamingMode::Series, "") => remarks_marker(&inv.number), + (NamingMode::Series, notes) => format!("{}\n{notes}", remarks_marker(&inv.number)), + (NamingMode::Mirror, notes) => notes.to_string(), + }; + set_if(&mut body, "remarks", &remarks); + + // Addresses are sent whenever known; under India Compliance a missing one makes it recompute the + // taxes and replace ours. + set_if(&mut body, "company_address", &cfg.company_address); + if let Some(addr) = ctx.customer_address { + set_if(&mut body, "customer_address", addr); + } + if gst_fields { + let pos = if inv.place_of_supply_state_code.trim().is_empty() { + ctx.vendor.state_code.as_str() + } else { + inv.place_of_supply_state_code.trim() + }; + body.insert("place_of_supply".into(), json!(place_of_supply_label(pos)?)); + body.insert("is_reverse_charge".into(), json!(i32::from(inv.reverse_charge))); + } + + if totals.discount > 0 { + body.insert("apply_discount_on".into(), json!("Net Total")); + 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)?)); + 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 }, + body: Value::Object(body), + idempotent: mirror, + }) +} + +// ---- Customer and Address ---- + +fn has_gstin(client: &Client) -> bool { + let g = client.gstin.trim(); + !g.is_empty() && !g.eq_ignore_ascii_case("NA") +} + +pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bool) -> Result { + let name = client.name.trim(); + if name.is_empty() { + return Err("The client has no name.".into()); + } + let mut body = Map::new(); + body.insert("customer_name".into(), json!(name)); + body.insert("customer_type".into(), json!("Company")); + 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"); + 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 }) +} + +/// `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 { + if customer.trim().is_empty() { + return Err("The ERPNext customer is not resolved.".into()); + } + let line1 = if client.address_line1.trim().is_empty() { + first_line_and_rest(client.address.trim()).0.trim() + } else { + client.address_line1.trim() + }; + if line1.is_empty() { + return Err("The client address needs a first line.".into()); + } + if client.city.trim().is_empty() { + return Err("The client address needs a city.".into()); + } + let mut body = Map::new(); + body.insert("address_title".into(), json!(client.name.trim())); + body.insert("address_type".into(), json!("Billing")); + body.insert("address_line1".into(), json!(line1)); + set_if(&mut body, "address_line2", &client.address_line2); + body.insert("city".into(), json!(client.city.trim())); + // The state must match India Compliance's list exactly; it is also what the GSTIN's first digits imply. + let state = client.state_code.trim(); + if state.is_empty() { + if india_compliance { + return Err("The client address needs a state.".into()); + } + } else { + body.insert("state".into(), json!(state_name(state)?)); + } + 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( + "links".into(), + json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]), + ); + Ok(BuiltRequest { path: ADDRESS, body: Value::Object(body), idempotent: false }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl}; + use crate::models::InvoiceInput; + use rusqlite::Connection; + use std::path::Path; + + const GSTIN: &str = "27AAPFU0939F1ZV"; + + fn registered() -> Connection { + let conn = crate::db::open_in_memory().unwrap(); + conn.execute( + "UPDATE app_settings SET gst_registration = 'regular', vendor_gstin = ?1, + vendor_state_code = '27', vendor_pan = 'AAPFU0939F', default_tax_type = 'cgst_sgst'", + [GSTIN], + ) + .unwrap(); + conn + } + + fn issue(conn: &mut Connection, extra: Value) -> Invoice { + let mut base = json!({ + "invoiceDate": "2026-04-01", + "dueDate": "2026-05-01", + "clientName": "Client Ltd", + "taxType": "cgst_sgst", + "taxRate": 18.0, + "items": [{"description": "Design", "mode": "fixed", "amount": 7310.0}], + }); + for (k, v) in extra.as_object().unwrap() { + base[k] = v.clone(); + } + let input: InvoiceInput = serde_json::from_value(base).unwrap(); + let inv = issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})).unwrap(); + get_invoice_impl(conn, inv.id).unwrap() + } + + fn cfg() -> ErpnextConfig { + ErpnextConfig { + company: "Arun Co".into(), + company_address: "Arun Co-Billing".into(), + income_account: "Sales - AC".into(), + cgst_account: "Output CGST - AC".into(), + sgst_account: "Output SGST - AC".into(), + utgst_account: "Output UTGST - AC".into(), + igst_account: "Output IGST - AC".into(), + ..Default::default() + } + } + + fn vendor(inv: &Invoice) -> Vendor { + Vendor::from_snapshot(&inv.vendor_snapshot).unwrap() + } + + fn build(inv: &Invoice, cfg: &ErpnextConfig, ic: bool, codes: &[Option], submit: bool) -> Result { + let v = vendor(inv); + build_sales_invoice(&InvoiceContext { + invoice: inv, + config: cfg, + vendor: &v, + customer: "Client Ltd", + customer_address: Some("Client Ltd-Billing"), + item_codes: codes, + india_compliance: ic, + submit, + }) + } + + /// What ERPNext computes for an `On Net Total` invoice with rounding disabled: round-half-up on every + /// row amount and tax amount. Returns the grand total in paise. + fn erpnext_grand_total(body: &Value) -> i64 { + let paise = |v: &Value| (v.as_f64().unwrap() * 100.0).round() as i128; + let mut total_amount: i128 = 0; + for row in body["items"].as_array().unwrap() { + let qty_milli = (row["qty"].as_f64().unwrap() * 1000.0).round() as i128; + total_amount += (paise(&row["rate"]) * qty_milli + 500) / 1000; + } + let discount = body.get("discount_amount").map(paise).unwrap_or(0); + let net = total_amount - discount; + let mut taxes: i128 = 0; + for row in body["taxes"].as_array().unwrap() { + let rate_milli = (row["rate"].as_f64().unwrap() * 1000.0).round() as i128; + taxes += (net * rate_milli + 50_000) / 100_000; + } + (net + taxes) as i64 + } + + fn client() -> Client { + serde_json::from_value(json!({ + "name": "Client Ltd", + "gstin": "29AABCU9603R1ZJ", + "stateCode": "29", + "addressLine1": "12 MG Road", + "addressLine2": "Floor 3", + "city": "Bengaluru", + "pincode": "560001", + "gstCategory": "registered_regular", + })) + .unwrap() + } + + #[test] + fn decimals_are_exact() { + assert_eq!(paise_to_decimal(0), "0.00"); + assert_eq!(paise_to_decimal(5), "0.05"); + assert_eq!(paise_to_decimal(731_000), "7310.00"); + assert_eq!(paise_to_decimal(-1234), "-12.34"); + assert_eq!(scaled_decimal(900, 2), "9"); + assert_eq!(scaled_decimal(925, 2), "9.25"); + 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"); + } + + #[test] + fn every_state_code_has_the_india_compliance_label() { + assert_eq!(place_of_supply_label("29").unwrap(), "29-Karnataka"); + assert_eq!(place_of_supply_label("01").unwrap(), "01-Jammu and Kashmir"); + 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!(place_of_supply_label("28").is_err()); + } + + #[test] + fn intra_state_cgst_sgst_mirrored_with_india_compliance() { + let mut conn = registered(); + let inv = issue(&mut conn, json!({})); + let req = build(&inv, &cfg(), true, &[None], false).unwrap(); + assert_eq!(req.path.join("/"), "api/v2/document/Sales Invoice"); + assert!(req.idempotent); + assert_eq!( + req.body, + json!({ + "name": inv.number, + "company": "Arun Co", + "customer": "Client Ltd", + "posting_date": "2026-04-01", + "set_posting_time": 1, + "due_date": "2026-05-01", + "ignore_pricing_rule": 1, + "disable_rounded_total": 1, + "company_address": "Arun Co-Billing", + "customer_address": "Client Ltd-Billing", + "place_of_supply": "27-Maharashtra", + "is_reverse_charge": 0, + "items": [{ + "item_name": "Design", + "description": "Design", + "qty": 1.0, + "rate": 7310.0, + "uom": "Nos", + "conversion_factor": 1, + "income_account": "Sales - AC", + }], + "taxes": [ + { "charge_type": "On Net Total", "account_head": "Output CGST - AC", "rate": 9.0, "description": "CGST @ 9%" }, + { "charge_type": "On Net Total", "account_head": "Output SGST - AC", "rate": 9.0, "description": "SGST @ 9%" }, + ], + }) + ); + assert_eq!(erpnext_grand_total(&req.body), inv.total_paise); + assert_eq!(inv.total_paise, 862_580); + } + + #[test] + fn inter_state_igst_with_discount_hsn_and_reverse_charge() { + let mut conn = registered(); + let inv = issue( + &mut conn, + json!({ + "placeOfSupplyStateCode": "29", + "taxType": "igst", + "taxRate": 5.0, + "reverseCharge": true, + "discount": 10.0, + "notes": "Thank you", + "poNumber": "PO-77", + "items": [ + {"description": "Hours", "mode": "rate", "rate": 1200.0, "quantity": 1.5, "unit": "hour", "hsnSac": "998314"}, + {"description": "Fee", "mode": "fixed", "amount": 33.33}, + ], + }), + ); + let req = build(&inv, &cfg(), true, &[None, None], false).unwrap(); + assert_eq!( + req.body, + json!({ + "name": inv.number, + "company": "Arun Co", + "customer": "Client Ltd", + "posting_date": "2026-04-01", + "set_posting_time": 1, + "due_date": "2026-05-01", + "po_no": "PO-77", + "ignore_pricing_rule": 1, + "disable_rounded_total": 1, + "remarks": "Thank you", + "company_address": "Arun Co-Billing", + "customer_address": "Client Ltd-Billing", + "place_of_supply": "29-Karnataka", + "is_reverse_charge": 1, + "apply_discount_on": "Net Total", + "discount_amount": 10.0, + "items": [ + { + "item_name": "Hours", "description": "Hours", "qty": 1.5, "rate": 1200.0, "uom": "Hour", + "conversion_factor": 1, "income_account": "Sales - AC", "gst_hsn_code": "998314", + }, + { + "item_name": "Fee", "description": "Fee", "qty": 1.0, "rate": 33.33, "uom": "Nos", + "conversion_factor": 1, "income_account": "Sales - AC", + }, + ], + "taxes": [ + { "charge_type": "On Net Total", "account_head": "Output IGST - AC", "rate": 5.0, "description": "IGST @ 5%" }, + ], + }) + ); + // (1833.33 - 10.00) * 5% = 91.1665 -> 91.17; ERPNext's grand total must equal Voiced's 1914.50. + assert_eq!(inv.total_paise, 191_450); + assert_eq!(erpnext_grand_total(&req.body), inv.total_paise); + } + + #[test] + fn discount_with_cgst_sgst_keeps_the_grand_total() { + let mut conn = registered(); + let inv = issue( + &mut conn, + json!({ "discount": 123.45, "taxRate": 12.0, "items": [ + {"description": "A", "mode": "rate", "rate": 99.99, "quantity": 3.333, "unit": "minute"}, + {"description": "B", "mode": "fixed", "amount": 1000.01}, + ]}), + ); + let req = build(&inv, &cfg(), true, &[None, None], false).unwrap(); + assert_eq!(req.body["discount_amount"].to_string(), "123.45"); + assert_eq!(req.body["items"][0]["uom"], "Minute"); + assert_eq!(erpnext_grand_total(&req.body), inv.total_paise); + } + + #[test] + fn unregistered_vendor_sends_no_tax_rows_and_no_gst_fields() { + let mut conn = crate::db::open_in_memory().unwrap(); + let inv = issue( + &mut conn, + json!({ "taxType": "none", "taxRate": 0.0, "items": [{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}] }), + ); + let req = build(&inv, &cfg(), true, &[None], false).unwrap(); + assert_eq!(req.body["taxes"], json!([])); + for key in ["place_of_supply", "is_reverse_charge"] { + assert!(req.body.get(key).is_none(), "{key}"); + } + assert!(req.body["items"][0].get("gst_hsn_code").is_none()); + assert_eq!(erpnext_grand_total(&req.body), 50_000); + assert_eq!(inv.total_paise, 50_000); + } + + #[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 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["items"][0].get("gst_hsn_code").is_none()); + } + + #[test] + fn code_less_rows_get_distinct_descriptions() { + let mut conn = registered(); + let inv = issue( + &mut conn, + json!({ "items": [ + {"description": "Consulting\nMarch", "mode": "fixed", "amount": 100.0}, + {"description": "consulting\nMarch", "mode": "fixed", "amount": 100.0}, + {"description": "Consulting\nMarch", "mode": "fixed", "amount": 100.0}, + {"description": "Other", "mode": "fixed", "amount": 1.0}, + ]}), + ); + let req = build(&inv, &cfg(), true, &[None, None, None, None], false).unwrap(); + let rows = req.body["items"].as_array().unwrap(); + assert_eq!(rows[0]["description"], "Consulting\nMarch"); + assert_eq!(rows[0]["item_name"], "Consulting"); + assert_eq!(rows[1]["description"], "consulting (2)\nMarch"); + assert_eq!(rows[1]["item_name"], "consulting (2)"); + assert_eq!(rows[2]["item_name"], "Consulting (3)"); + assert_eq!(rows[3]["item_name"], "Other"); + } + + #[test] + fn code_less_rows_need_an_income_account() { + let mut conn = registered(); + let inv = issue(&mut conn, json!({})); + let mut c = cfg(); + c.income_account.clear(); + let err = build(&inv, &c, true, &[None], false).unwrap_err(); + assert!(err.contains("income account"), "{err}"); + // With an item code the row does not need one. + let ok = build(&inv, &c, true, &[Some("SERVICE".into())], false).unwrap(); + assert!(ok.body["items"][0].get("income_account").is_none()); + } + + #[test] + fn item_code_rows_use_the_code_and_fall_back_to_the_default() { + let mut conn = registered(); + let inv = issue( + &mut conn, + json!({ "items": [ + {"description": "Logo design", "mode": "fixed", "amount": 100.0}, + {"description": "Hosting", "mode": "rate", "rate": 10.0, "quantity": 2.0, "unit": "session"}, + ]}), + ); + let mut c = cfg(); + let req = build(&inv, &c, true, &[Some("DESIGN-01".into()), None], false).unwrap(); + let rows = req.body["items"].as_array().unwrap(); + assert_eq!(rows[0]["item_code"], "DESIGN-01"); + assert!(rows[0].get("item_name").is_none()); + assert_eq!(rows[0]["description"], "Logo design"); + assert_eq!(rows[1]["item_name"], "Hosting"); + + c.default_item_code = "SERVICES".into(); + let req = build(&inv, &c, true, &[Some("DESIGN-01".into()), None], false).unwrap(); + assert_eq!(req.body["items"][1]["item_code"], "SERVICES"); + assert!(req.body["items"][1].get("item_name").is_none()); + assert_eq!(req.body["items"][1]["uom"], "Nos"); + } + + #[test] + fn series_mode_uses_the_series_endpoint_and_puts_the_number_in_remarks() { + let mut conn = registered(); + let inv = issue(&mut conn, json!({ "notes": "Net 30" })); + let mut c = cfg(); + c.naming_mode = NamingMode::Series; + c.naming_series = "SINV-.YY.-".into(); + let req = build(&inv, &c, true, &[None], false).unwrap(); + assert_eq!(req.path.join("/"), "api/resource/Sales Invoice"); + 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)); + + c.naming_series.clear(); + assert!(build(&inv, &c, true, &[None], false).is_err()); + } + + #[test] + 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); + } + + #[test] + fn fractional_gst_rates_and_union_territory_suppliers() { + let mut conn = registered(); + let inv = issue(&mut conn, json!({ "taxRate": 5.25 })); + let req = build(&inv, &cfg(), true, &[None], false).unwrap(); + assert_eq!(req.body["taxes"][0]["rate"], json!(2.625)); + assert_eq!(req.body["taxes"][0]["description"], "CGST @ 2.625%"); + assert_eq!(erpnext_grand_total(&req.body), inv.total_paise); + + // 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\""); + 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%"); + } + + #[test] + fn totals_that_disagree_with_the_stored_invoice_are_refused() { + let mut conn = registered(); + let mut inv = issue(&mut conn, json!({})); + inv.total += 0.01; + let err = build(&inv, &cfg(), true, &[None], false).unwrap_err(); + assert!(err.contains("differs from the stored value"), "{err}"); + + let mut inv = issue(&mut conn, json!({})); + inv.items[0].quantity = 1.0005; + inv.items[0].mode = "rate".into(); + inv.items[0].rate = 7310.0; + assert!(build(&inv, &cfg(), true, &[None], false).is_err()); + } + + #[test] + fn cancelled_invoices_and_missing_settings_are_refused() { + let mut conn = registered(); + 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")); + c.company.clear(); + 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")); + } + + #[test] + fn due_date_before_the_invoice_date_is_left_out() { + 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()); + inv.due_date.clear(); + assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none()); + } + + #[test] + fn customer_payload_under_india_compliance() { + let mut c = cfg(); + c.customer_group = "Commercial".into(); + c.territory = "India".into(); + let req = build_customer(&client(), &c, true).unwrap(); + assert_eq!(req.path.join("/"), "api/resource/Customer"); + assert_eq!( + req.body, + json!({ + "customer_name": "Client Ltd", + "customer_type": "Company", + "customer_group": "Commercial", + "territory": "India", + "gst_category": "Registered Regular", + "gstin": "29AABCU9603R1ZJ", + }) + ); + // Plain ERPNext gets no GST fields. + let plain = build_customer(&client(), &c, false).unwrap(); + assert!(plain.body.get("gstin").is_none() && plain.body.get("gst_category").is_none()); + } + + #[test] + fn customer_gst_categories_map_and_bad_gstin_is_refused() { + for (voiced, erp) in [ + ("registered_regular", "Registered Regular"), + ("composition", "Registered Composition"), + ("unregistered", "Unregistered"), + ("sez", "SEZ"), + ("overseas", "Overseas"), + ] { + let mut c = client(); + c.gst_category = voiced.into(); + if voiced == "unregistered" || voiced == "overseas" { + c.gstin = String::new(); + } + let req = build_customer(&c, &cfg(), true).unwrap(); + assert_eq!(req.body["gst_category"], erp); + } + let mut unreg = client(); + unreg.gst_category = "unregistered".into(); + 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")); + } + + #[test] + fn address_payload_links_the_customer_and_uses_india_compliance_state_names() { + let req = build_address(&client(), "Client Ltd - 1", true).unwrap(); + assert_eq!(req.path.join("/"), "api/resource/Address"); + assert_eq!( + req.body, + json!({ + "address_title": "Client Ltd", + "address_type": "Billing", + "address_line1": "12 MG Road", + "address_line2": "Floor 3", + "city": "Bengaluru", + "state": "Karnataka", + "pincode": "560001", + "country": "India", + "gst_category": "Registered Regular", + "gstin": "29AABCU9603R1ZJ", + "links": [{ "link_doctype": "Customer", "link_name": "Client Ltd - 1" }], + }) + ); + let mut jk = client(); + jk.state_code = "01".into(); + assert_eq!(build_address(&jk, "X", true).unwrap().body["state"], "Jammu and Kashmir"); + } + + #[test] + fn address_falls_back_to_the_composed_text_and_checks_required_parts() { + 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"); + + let mut no_city = client(); + no_city.city.clear(); + assert!(build_address(&no_city, "X", true).is_err()); + 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(&client(), " ", true).is_err()); + } +} diff --git a/src-tauri/src/integrations/erpnext/mod.rs b/src-tauri/src/integrations/erpnext/mod.rs new file mode 100644 index 0000000..e62f691 --- /dev/null +++ b/src-tauri/src/integrations/erpnext/mod.rs @@ -0,0 +1,14 @@ +//! ERPNext / Frappe integration. All HTTP runs in Rust (see `client`); `mapping` is pure and +//! snapshot-tested; `config` owns the stored settings and the API secret. + +// Parts of this module are consumed by the push commands in the next step. +#![allow(dead_code)] + +pub mod client; +pub mod config; +pub mod discovery; +pub mod errors; +pub mod mapping; + +#[cfg(test)] +pub(crate) mod testutil; diff --git a/src-tauri/src/integrations/erpnext/testutil.rs b/src-tauri/src/integrations/erpnext/testutil.rs new file mode 100644 index 0000000..85e5304 --- /dev/null +++ b/src-tauri/src/integrations/erpnext/testutil.rs @@ -0,0 +1,117 @@ +//! A tiny HTTP/1.1 mock server for client tests (one request per connection, scripted or routed replies). + +use super::client::{ClientOptions, ErpClient}; +use super::config::Secret; +use serde_json::Value; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +pub struct Reply { + pub status: u16, + pub headers: Vec<(&'static str, String)>, + pub body: String, + /// Pause before answering, to trigger the read timeout. + pub delay_ms: u64, +} + +pub fn reply(status: u16, body: Value) -> Reply { + Reply { status, headers: vec![], body: body.to_string(), delay_ms: 0 } +} + +pub struct Mock { + pub base: String, + pub hits: Arc, + /// Raw requests (head and body) in arrival order. + pub requests: Arc>>, +} + +/// Serves the scripted replies in order; the last one repeats. +pub async fn serve(script: Vec) -> 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 } + }) + .await +} + +/// Answers every request with `handler(request_index, request_line)`. +pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'static) -> Mock { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let hits = Arc::new(AtomicUsize::new(0)); + let requests = Arc::new(Mutex::new(Vec::new())); + let (h, r) = (hits.clone(), requests.clone()); + let handler = Arc::new(handler); + tokio::spawn(async move { + loop { + 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(); + let mut chunk = [0u8; 2048]; + loop { + let n = sock.read(&mut chunk).await.unwrap_or(0); + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + if let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&buf[..end]).to_string(); + let want = head + .lines() + .find_map(|l| { + l.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|v| v.trim().parse::().unwrap_or(0)) + }) + .unwrap_or(0); + if buf.len() >= end + 4 + want { + break; + } + } + } + let raw = String::from_utf8_lossy(&buf).to_string(); + let request_line = raw.lines().next().unwrap_or("").to_string(); + r.lock().unwrap().push(raw); + let i = h.fetch_add(1, Ordering::SeqCst); + let rep = handler(i, &request_line); + if rep.delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(rep.delay_ms)).await; + } + let mut out = format!( + "HTTP/1.1 {} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n", + rep.status, + rep.body.len() + ); + for (k, v) in &rep.headers { + out.push_str(&format!("{k}: {v}\r\n")); + } + out.push_str("\r\n"); + out.push_str(&rep.body); + let _ = sock.write_all(out.as_bytes()).await; + let _ = sock.shutdown().await; + }); + } + }); + Mock { base: format!("http://127.0.0.1:{port}"), hits, requests } +} + +/// Short timeouts and near-zero backoff so retry tests run in milliseconds. +pub fn fast_opts() -> ClientOptions { + ClientOptions { + connect_timeout: Duration::from_secs(2), + read_timeout: Duration::from_millis(250), + total_timeout: Duration::from_secs(5), + max_retries: 3, + backoff_base: Duration::from_millis(1), + max_backoff: Duration::from_millis(5), + } +} + +pub fn client(mock: &Mock) -> ErpClient { + ErpClient::new(&mock.base, "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap() +} diff --git a/src-tauri/src/integrations/mod.rs b/src-tauri/src/integrations/mod.rs new file mode 100644 index 0000000..bcff5ea --- /dev/null +++ b/src-tauri/src/integrations/mod.rs @@ -0,0 +1,38 @@ +//! Outbound integrations. `InvoiceSink` is the seam between Voiced and a system that receives issued +//! invoices (ERPNext now; a webhook, Zoho or a Tally import later). Everything target-specific lives in a +//! submodule; the rest of the app only talks to this trait. + +// The push commands that use the sink arrive in the next step. +#![allow(dead_code)] + +pub mod erpnext; + +use crate::models::Invoice; +use std::future::Future; + +/// What a sink reports after accepting an invoice. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PushedInvoice { + /// The document name on the remote system. + pub remote_name: String, + /// 0 draft, 1 submitted. + pub remote_docstatus: i64, + /// False when the remote already had this invoice (an idempotent re-push). + pub created: bool, +} + +pub struct PushRequest<'a> { + pub invoice: &'a Invoice, + /// Submit on the remote system instead of leaving a draft for review. + pub submit: bool, +} + +pub trait InvoiceSink: Send + Sync { + /// Stable identifier used in the sync table (`erpnext`, ...). + fn id(&self) -> &'static str; + + fn push_invoice<'a>( + &'a self, + request: PushRequest<'a>, + ) -> impl Future> + Send + 'a; +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bdbe16a..ceab8c8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod db; mod gst; +mod integrations; mod logo; mod models; mod selftest; @@ -176,6 +177,10 @@ pub fn run() { commands::files::get_last_export_dir, commands::files::reveal_in_folder, commands::files::open_file, + commands::erpnext::erpnext_get_config, + commands::erpnext::erpnext_save_config, + commands::erpnext::erpnext_test_connection, + commands::erpnext::erpnext_load_options, selftest::selftest_config, selftest::selftest_report, ]) diff --git a/src/lib/api.ts b/src/lib/api.ts index 63aadd1..9e1997e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -14,6 +14,12 @@ import type { LogoAsset } from "./logo"; import type { Payment, PaymentInput } from "./payments"; import type { BackupStatus, BackupSummary, PendingRestore } from "./backup"; import type { LedgerRow } from "./historyExport"; +import type { + ErpnextConfig, + ErpnextConfigInput, + ErpnextConnectionTest, + ErpnextOptions, +} from "./erpnext"; import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport"; export interface ArchiveStatus { @@ -113,4 +119,11 @@ export const api = { getBackupStatus: () => invoke("get_backup_status"), setAutoBackup: (enabled: boolean) => invoke("set_auto_backup", { enabled }), restartApp: () => invoke("restart_app"), + + // ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview. + erpnextGetConfig: () => invoke("erpnext_get_config"), + erpnextSaveConfig: (config: ErpnextConfigInput) => invoke("erpnext_save_config", { config }), + erpnextTestConnection: (config: ErpnextConfigInput) => + invoke("erpnext_test_connection", { config }), + erpnextLoadOptions: (config: ErpnextConfigInput) => invoke("erpnext_load_options", { config }), }; diff --git a/src/lib/erpnext.ts b/src/lib/erpnext.ts new file mode 100644 index 0000000..0e66891 --- /dev/null +++ b/src/lib/erpnext.ts @@ -0,0 +1,90 @@ +/** Types for the ERPNext commands (see src-tauri/src/commands/erpnext.rs). Types only: no UI yet. */ + +export type ErpnextNamingMode = "mirror" | "series"; + +/** What the backend returns. The API secret is never included: `apiSecretSet` says whether one is stored. */ +export interface ErpnextConfig { + baseUrl: string; + apiKey: string; + apiSecretSet: boolean; + extraCaPem: string; + company: string; + companyAddress: string; + namingMode: ErpnextNamingMode; + namingSeries: string; + incomeAccount: string; + costCenter: string; + cgstAccount: string; + sgstAccount: string; + utgstAccount: string; + igstAccount: string; + taxTemplateIntra: string; + taxTemplateInter: string; + paymentBankAccount: string; + tdsAccount: string; + defaultItemCode: string; + /** Voiced unit (second, minute, hour, session, unit) to ERPNext UOM. */ + uomMap: Record; + customerGroup: string; + territory: string; + sellingPriceList: string; + submitOnPush: boolean; + attachPdf: boolean; + autoPushOnIssue: boolean; + createMissingCustomers: boolean; + /** The last connection test, or null if none was stored. */ + lastDetectResult: ErpnextConnectionTest | null; +} + +/** + * What the webview sends to save or test. A blank `apiSecret` keeps the stored secret, a value replaces it, + * and `clearSecret` removes it. `lastDetectResult` omitted keeps the stored one. + */ +export type ErpnextConfigInput = Omit & { + apiSecret: string; + clearSecret?: boolean; + lastDetectResult?: ErpnextConnectionTest | null; +}; + +export interface ErpnextWarning { + code: string; + message: string; +} + +export interface ErpnextVersions { + frappe: string | null; + erpnext: string | null; + indiaCompliance: string | null; +} + +export interface ErpnextConnectionTest { + user: string; + versions: ErpnextVersions; + features: { v2Naming: boolean; indiaCompliance: boolean }; + indiaCompliance: boolean; + warnings: ErpnextWarning[]; +} + +export interface ErpnextOptionItem { + /** The value to store (the document name). */ + name: string; + label: string; + detail: string; +} + +export interface ErpnextOptions { + companies: ErpnextOptionItem[]; + companyAddresses: ErpnextOptionItem[]; + incomeAccounts: ErpnextOptionItem[]; + taxAccounts: ErpnextOptionItem[]; + taxTemplates: ErpnextOptionItem[]; + namingSeries: ErpnextOptionItem[]; + itemGroups: ErpnextOptionItem[]; + uoms: ErpnextOptionItem[]; + costCenters: ErpnextOptionItem[]; + priceLists: ErpnextOptionItem[]; + customerGroups: ErpnextOptionItem[]; + territories: ErpnextOptionItem[]; + /** Lists that failed to load; the others are still filled. */ + errors: { list: string; message: string }[]; +}