Add the ERPNext integration foundation (Phase F1)

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

The API secret is stored in the SQLite file, so backups contain it. Nothing has run against a live ERPNext yet.
This commit is contained in:
2026-10-04 19:50:24 +05:30
parent 4cf019988d
commit e1d8b07a67
16 changed files with 4254 additions and 9 deletions
+401 -8
View File
@@ -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"
+6
View File
@@ -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
+153
View File
@@ -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<ErpnextConfigView, String> {
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<ErpnextConfig, String> {
let stored = config::load(conn)?;
input.resolve(&stored)
}
pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfigView, String> {
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<LocalFacts, String> {
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::<rusqlite::Result<Vec<_>>>()
.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<AppState>) -> Result<ErpnextConfigView, String> {
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<AppState>, config: ErpnextConfigInput) -> Result<ErpnextConfigView, String> {
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<ConnectionTest, String> {
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<ErpnextOptions, String> {
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": "Test 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\":\"Test 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, "Test 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 ('INV/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"]);
}
}
+1
View File
@@ -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;
+132 -1
View File
@@ -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::<serde_json::Value>(&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 ('INV/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<String>, Option<String>) = 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<String>) = 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<String>) = 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, "INV/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, 'INV/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();
@@ -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<String, ErpError> {
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<String>,
}
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<Self, ErpError> {
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, ErpError> {
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<Value, ErpError> {
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<Value, ErpError> {
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<Value, ErpError> {
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::<u64>().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<Duration>) {
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<Value, ErpError> {
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": "Sample@example.com" }))]).await;
let value = client(&mock)
.get(&["api", "method", "frappe.auth.get_logged_user"], &[])
.await
.unwrap();
assert_eq!(value["message"], "Sample@example.com");
let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase();
assert!(raw.starts_with("get /api/method/frappe.auth.get_logged_user "), "{raw}");
assert!(raw.contains("authorization: token thekey:thesecret"), "{raw}");
assert!(raw.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", "INV/2026-001"], &[("limit_page_length", "0".into())])
.await
.unwrap();
let raw = mock.requests.lock().unwrap()[0].clone();
assert!(raw.starts_with("GET /api/resource/Sales%20Invoice/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 = "<html>login</html>".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);
}
}
@@ -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<String>) -> 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<Self, String> {
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<String, String> {
[
("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<String, String>,
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<String, String>,
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<String, String>,
#[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<Value>,
}
fn default_naming_mode() -> String {
"mirror".to_string()
}
fn yes() -> bool {
true
}
fn origin_of(url: &str) -> String {
url.split('/').take(3).collect::<Vec<_>>().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<ErpnextConfig, String> {
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<ErpnextConfig, String> {
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::<BTreeMap<String, String>>(&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": "Test 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, "Test 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");
}
}
@@ -0,0 +1,840 @@
//! What the connected site is (versions, India Compliance), the lists the settings dropdowns need, and the
//! connection test that turns all of it into warnings.
use super::client::ErpClient;
use super::config::{ErpnextConfig, NamingMode};
use super::errors::{ErpError, ErrorKind};
use serde::Serialize;
use serde_json::{json, Value};
// ---- versions and feature switches ----
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Version {
/// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored.
pub fn parse(s: &str) -> Option<Version> {
let s = s.trim().trim_start_matches(['v', 'V']);
let numeric: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
let mut parts = numeric.split('.').filter(|p| !p.is_empty());
let major = parts.next()?.parse().ok()?;
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
Some(Version { major, minor, patch })
}
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerVersions {
pub frappe: Option<String>,
pub erpnext: Option<String>,
pub india_compliance: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FeatureSwitches {
/// API v2 keeps a posted document name verbatim (Frappe >= 15.73, or 16+).
pub v2_naming: bool,
/// India Compliance is installed, so the GST header and item fields apply.
pub india_compliance: bool,
}
pub fn feature_switches(v: &ServerVersions) -> FeatureSwitches {
let frappe = v.frappe.as_deref().and_then(Version::parse);
FeatureSwitches {
v2_naming: frappe.is_some_and(|f| (f.major, f.minor) >= (15, 73)),
india_compliance: v.india_compliance.is_some(),
}
}
/// Reads `{"message": {"frappe": {"version": "15.73.0"}, ...}}`.
pub fn parse_versions(value: &Value) -> ServerVersions {
let apps = value.get("message").unwrap_or(value);
let version_of = |app: &str| -> Option<String> {
let entry = apps.get(app)?;
let v = entry.get("version").and_then(Value::as_str).or_else(|| entry.as_str())?;
let v = v.trim();
(!v.is_empty()).then(|| v.to_string())
};
ServerVersions {
frappe: version_of("frappe"),
erpnext: version_of("erpnext"),
india_compliance: version_of("india_compliance"),
}
}
// ---- number rules (India Compliance) ----
pub const IC_MAX_NUMBER_LEN: usize = 16;
/// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`.
pub fn ic_number_ok(number: &str) -> bool {
let mut chars = number.chars();
let Some(first) = chars.next() else { return false };
first.is_alphanumeric()
&& number.chars().count() <= IC_MAX_NUMBER_LEN
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/')
}
/// Length of a number generated from a Frappe naming series such as `SINV-.YY.-.#####`. A series with no
/// `#` run gets Frappe's default five digits.
pub fn naming_series_expanded_len(series: &str) -> usize {
let mut len = 0;
let mut has_digits = false;
for part in series.split('.') {
if part.is_empty() {
continue;
}
if part.chars().all(|c| c == '#') {
len += part.len();
has_digits = true;
continue;
}
len += match part {
"YY" | "MM" | "DD" => 2,
"YYYY" => 4,
"FY" => 7,
literal => literal.chars().count(),
};
}
if !has_digits {
len += 5;
}
len
}
// ---- options for the settings dropdowns ----
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OptionItem {
/// The value to store (the document name).
pub name: String,
pub label: String,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct OptionError {
pub list: String,
pub message: String,
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ErpnextOptions {
pub companies: Vec<OptionItem>,
pub company_addresses: Vec<OptionItem>,
pub income_accounts: Vec<OptionItem>,
pub tax_accounts: Vec<OptionItem>,
pub tax_templates: Vec<OptionItem>,
pub naming_series: Vec<OptionItem>,
pub item_groups: Vec<OptionItem>,
pub uoms: Vec<OptionItem>,
pub cost_centers: Vec<OptionItem>,
pub price_lists: Vec<OptionItem>,
pub customer_groups: Vec<OptionItem>,
pub territories: Vec<OptionItem>,
/// Lists that could not be loaded (one failing list does not hide the rest).
pub errors: Vec<OptionError>,
}
fn text<'a>(row: &'a Value, key: &str) -> &'a str {
row.get(key).and_then(Value::as_str).unwrap_or("").trim()
}
fn item(row: &Value, label_key: &str, detail: String) -> Option<OptionItem> {
let name = text(row, "name");
if name.is_empty() {
return None;
}
let label = text(row, label_key);
Some(OptionItem {
name: name.to_string(),
label: if label.is_empty() { name.to_string() } else { label.to_string() },
detail,
})
}
fn join(parts: &[&str]) -> String {
parts.iter().filter(|p| !p.is_empty()).cloned().collect::<Vec<_>>().join(" · ")
}
/// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank.
pub fn parse_naming_series(doctype_response: &Value) -> Vec<OptionItem> {
let docs = doctype_response
.get("docs")
.or_else(|| doctype_response.get("message").and_then(|m| m.get("docs")))
.and_then(Value::as_array);
let Some(doc) = docs.and_then(|d| d.first()) else { return Vec::new() };
let Some(fields) = doc.get("fields").and_then(Value::as_array) else { return Vec::new() };
let Some(field) = fields.iter().find(|f| text(f, "fieldname") == "naming_series") else {
return Vec::new();
};
text(field, "options")
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| OptionItem { name: l.to_string(), label: l.to_string(), detail: format!("e.g. {} digits", naming_series_expanded_len(l)) })
.collect()
}
impl ErpClient {
pub async fn get_logged_user(&self) -> Result<String, ErpError> {
let v = self.get(&["api", "method", "frappe.auth.get_logged_user"], &[]).await?;
let user = v.get("message").and_then(Value::as_str).unwrap_or("").trim().to_string();
if user.is_empty() || user.eq_ignore_ascii_case("guest") {
return Err(ErpError {
kind: ErrorKind::Auth,
status: None,
message: "The API key and secret were not accepted (the site treated the request as Guest).".into(),
exc_type: None,
});
}
Ok(user)
}
pub async fn get_versions(&self) -> Result<ServerVersions, ErpError> {
let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?;
Ok(parse_versions(&v))
}
/// `GET /api/resource/<doctype>` with every row (`limit_page_length=0`).
pub async fn list_resource(
&self,
doctype: &str,
fields: &[&str],
filters: Value,
order_by: &str,
) -> Result<Vec<Value>, ErpError> {
let mut query = vec![
("fields", json!(fields).to_string()),
("limit_page_length", "0".to_string()),
("order_by", order_by.to_string()),
];
if !filters.is_null() {
query.push(("filters", filters.to_string()));
}
let v = self.get(&["api", "resource", doctype], &query).await?;
Ok(v.get("data").and_then(Value::as_array).cloned().unwrap_or_default())
}
pub async fn load_options(&self, company: &str) -> Result<ErpnextOptions, ErpError> {
let mut out = ErpnextOptions::default();
let company = company.trim();
macro_rules! load {
($list:literal, $target:expr, $call:expr) => {
match $call.await {
Ok(rows) => $target = rows,
Err(e) if matches!(e.kind, ErrorKind::Auth | ErrorKind::Network | ErrorKind::Timeout | ErrorKind::Config | ErrorKind::Protocol) => {
return Err(e)
}
Err(e) => out.errors.push(OptionError { list: $list.to_string(), message: e.to_string() }),
}
};
}
load!("companies", out.companies, async {
self.list_resource("Company", &["name", "company_name", "abbr", "country", "default_currency"], Value::Null, "name asc")
.await
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "company_name", join(&[text(r, "abbr"), text(r, "default_currency")])))
.collect()
})
});
load!("itemGroups", out.item_groups, async {
self.list_resource("Item Group", &["name"], Value::Null, "name asc")
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
});
load!("uoms", out.uoms, async {
self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc")
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
});
load!("priceLists", out.price_lists, async {
self.list_resource("Price List", &["name"], json!([["enabled", "=", 1], ["selling", "=", 1]]), "name asc")
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
});
load!("customerGroups", out.customer_groups, async {
self.list_resource("Customer Group", &["name"], json!([["is_group", "=", 0]]), "name asc")
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
});
load!("territories", out.territories, async {
self.list_resource("Territory", &["name"], json!([["is_group", "=", 0]]), "name asc")
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
});
load!("namingSeries", out.naming_series, async {
self.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Sales Invoice".to_string())])
.await
.map(|v| parse_naming_series(&v))
});
if company.is_empty() {
return Ok(out);
}
load!("companyAddresses", out.company_addresses, async {
self.list_resource(
"Address",
&["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"],
json!([
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", company],
["disabled", "=", 0]
]),
"name asc",
)
.await
.map(|rows| {
rows.iter()
.filter_map(|r| {
item(
r,
"address_title",
join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]),
)
})
.collect()
})
});
load!("incomeAccounts", out.income_accounts, async {
self.list_resource(
"Account",
&["name", "account_name", "account_type"],
json!([["company", "=", company], ["root_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]),
"name asc",
)
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect())
});
load!("taxAccounts", out.tax_accounts, async {
self.list_resource(
"Account",
&["name", "account_name", "account_type"],
json!([["company", "=", company], ["account_type", "=", "Tax"], ["is_group", "=", 0], ["disabled", "=", 0]]),
"name asc",
)
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect())
});
load!("taxTemplates", out.tax_templates, async {
self.list_resource(
"Sales Taxes and Charges Template",
&["name", "title", "is_default"],
json!([["company", "=", company], ["disabled", "=", 0]]),
"name asc",
)
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "title", String::new())).collect())
});
load!("costCenters", out.cost_centers, async {
self.list_resource(
"Cost Center",
&["name", "cost_center_name"],
json!([["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]),
"name asc",
)
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "cost_center_name", String::new())).collect())
});
Ok(out)
}
}
// ---- connection test and warnings ----
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Warning {
pub code: String,
pub message: String,
}
fn warn(code: &str, message: impl Into<String>) -> Warning {
Warning { code: code.to_string(), message: message.into() }
}
/// Facts from the local database that the warnings need.
#[derive(Debug, Clone, Default)]
pub struct LocalFacts {
pub vendor_registered: bool,
/// Numbers of issued/cancelled invoices that India Compliance would refuse (at most a few examples).
pub invalid_numbers: Vec<String>,
/// The number the next invoice would get, if a series is active.
pub next_number: Option<String>,
}
/// What was learned from the site. `None` means the check could not run.
#[derive(Debug, Clone, Default)]
pub struct RemoteFacts {
pub versions: Option<ServerVersions>,
pub company_address_count: Option<usize>,
/// Rows of GST Settings -> gst_accounts, or the reason they could not be read.
pub gst_accounts: Option<Result<Vec<Value>, String>>,
}
/// Checks that the configured tax accounts appear as Output accounts for the company in GST Settings.
/// Otherwise India Compliance silently files the items as Nil-Rated.
pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
let for_company: Vec<&Value> = rows
.iter()
.filter(|r| text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output"))
.collect();
if for_company.is_empty() {
return vec![warn(
"gst_accounts_not_configured",
format!(
"GST accounts are not configured for \"{}\" in GST Settings. India Compliance will file these invoices as Nil-Rated.",
cfg.company
),
)];
}
let has = |field: &str, account: &str| for_company.iter().any(|r| text(r, field) == account);
let mut out = Vec::new();
for (field, label, account) in [
("cgst_account", "CGST", cfg.cgst_account.as_str()),
("sgst_account", "SGST", cfg.sgst_account.as_str()),
("igst_account", "IGST", cfg.igst_account.as_str()),
] {
if !account.is_empty() && !has(field, account) {
out.push(warn(
"gst_account_mismatch",
format!("The {label} account \"{account}\" is not the {label} output account in GST Settings; GST returns would not pick the tax up."),
));
}
}
out
}
/// Pure: turns the configuration and the facts gathered into the list shown after a connection test.
pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFacts) -> Vec<Warning> {
let mut w = Vec::new();
let features = remote.versions.as_ref().map(feature_switches);
let ic = features.is_some_and(|f| f.india_compliance);
if remote.versions.is_none() {
w.push(warn(
"versions_unknown",
"The site's versions could not be read, so India Compliance and API v2 naming were not detected.",
));
}
if cfg.company.is_empty() {
w.push(warn("company_missing", "No company is selected."));
}
if cfg.income_account.is_empty() {
w.push(warn(
"income_account_missing",
"No income account is set. Rows without an item code need one, and ERPNext has no fallback for them.",
));
}
if cfg.naming_mode == NamingMode::Mirror {
if let Some(f) = features {
if !f.v2_naming {
w.push(warn(
"mirror_unsupported",
"Mirroring the Voiced number needs Frappe 15.73 or newer. Use the ERPNext series naming mode instead.",
));
}
}
} else if cfg.naming_series.is_empty() {
w.push(warn("naming_series_missing", "ERPNext series naming is selected but no naming series is chosen."));
}
if local.vendor_registered {
if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty() {
w.push(warn(
"tax_accounts_missing",
"The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.",
));
}
if remote.versions.is_some() && !ic {
w.push(warn(
"ic_not_detected",
"India Compliance is not installed on the site, so the GST fields (place of supply, HSN, GSTIN) will not be sent.",
));
}
}
if ic {
if local.vendor_registered {
match &remote.gst_accounts {
Some(Ok(rows)) => w.extend(check_gst_accounts(rows, cfg)),
Some(Err(why)) => w.push(warn(
"gst_settings_unreadable",
format!("GST Settings could not be read ({why}), so the GST accounts were not checked."),
)),
None => {}
}
}
if cfg.company_address.is_empty() {
w.push(warn("company_address_missing", "India Compliance needs a company address on every invoice; none is selected."));
} else if remote.company_address_count == Some(0) {
w.push(warn("company_address_missing", "No address is linked to the company on the site."));
}
match cfg.naming_mode {
NamingMode::Mirror => {
if !local.invalid_numbers.is_empty() {
w.push(warn(
"number_invalid",
format!(
"India Compliance allows at most {IC_MAX_NUMBER_LEN} characters (letters, digits, - and /). These numbers do not qualify: {}.",
local.invalid_numbers.join(", ")
),
));
}
if let Some(next) = &local.next_number {
if !ic_number_ok(next) {
w.push(warn(
"number_invalid",
format!("The next invoice number {next} is longer than {IC_MAX_NUMBER_LEN} characters or has characters India Compliance refuses."),
));
}
}
}
NamingMode::Series => {
let len = naming_series_expanded_len(&cfg.naming_series);
if !cfg.naming_series.is_empty() && len > IC_MAX_NUMBER_LEN {
w.push(warn(
"naming_series_too_long",
format!(
"The naming series \"{}\" produces numbers of about {len} characters; India Compliance allows {IC_MAX_NUMBER_LEN}.",
cfg.naming_series
),
));
}
}
}
}
w
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionTest {
pub user: String,
pub versions: ServerVersions,
pub features: FeatureSwitches,
/// Same as `features.india_compliance`, for the UI.
pub india_compliance: bool,
pub warnings: Vec<Warning>,
}
/// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only
/// produce warnings.
pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &LocalFacts) -> Result<ConnectionTest, ErpError> {
let user = client.get_logged_user().await?;
let versions = match client.get_versions().await {
Ok(v) => Some(v),
Err(e) if e.kind == ErrorKind::Auth => return Err(e),
Err(_) => None,
};
let mut remote = RemoteFacts { versions, ..Default::default() };
let features = remote.versions.as_ref().map(feature_switches);
if features.is_some_and(|f| f.india_compliance) {
if !cfg.company.is_empty() {
remote.company_address_count = client
.list_resource(
"Address",
&["name"],
json!([["Dynamic Link", "link_doctype", "=", "Company"], ["Dynamic Link", "link_name", "=", cfg.company], ["disabled", "=", 0]]),
"name asc",
)
.await
.ok()
.map(|rows| rows.len());
}
if local.vendor_registered {
remote.gst_accounts = Some(
client
.get(&["api", "resource", "GST Settings", "GST Settings"], &[])
.await
.map(|v| v.get("data").and_then(|d| d.get("gst_accounts")).and_then(Value::as_array).cloned().unwrap_or_default())
.map_err(|e| e.to_string()),
);
}
}
let warnings = build_warnings(cfg, local, &remote);
let versions = remote.versions.unwrap_or_default();
let features = feature_switches(&versions);
Ok(ConnectionTest { user, india_compliance: features.india_compliance, features, versions, warnings })
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> ErpnextConfig {
ErpnextConfig {
company: "Test Co".into(),
company_address: "Test 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": "Test 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 ["INV/2026-001", "A", "1234567890123456", "AP-24-25/0001"] {
assert!(ic_number_ok(ok), "{ok}");
}
for bad in ["", "12345678901234567", "_AB", "AB 01", "AB_01", "-AB", "/AB", "AB#1"] {
assert!(!ic_number_ok(bad), "{bad}");
}
}
#[test]
fn naming_series_is_expanded_before_measuring() {
assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5);
assert_eq!(naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"), 9 + 4 + 1 + 5);
assert_eq!(naming_series_expanded_len("INV-.MM.-.DD.-.####"), 4 + 2 + 1 + 2 + 1 + 4);
assert_eq!(naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"), 15 + 4 + 1 + 5);
}
#[test]
fn naming_series_options_come_from_getdoctype() {
let resp = json!({ "docs": [{ "fields": [
{ "fieldname": "customer", "options": "Customer" },
{ "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" }
]}]});
let items = parse_naming_series(&resp);
assert_eq!(items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(), ["SINV-.YY.-", "ACC-SINV-.YYYY.-"]);
assert!(parse_naming_series(&json!({})).is_empty());
}
#[test]
fn a_healthy_ic_setup_has_no_warnings() {
let local = LocalFacts { vendor_registered: true, invalid_numbers: vec![], next_number: Some("INV/2026-001".into()) };
assert_eq!(build_warnings(&cfg(), &local, &ic_versions("15.73.0")), vec![]);
}
#[test]
fn gst_accounts_not_configured_or_mismatched_are_flagged() {
let local = LocalFacts { vendor_registered: true, ..Default::default() };
let 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": "Test Co", "account_type": "Output",
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" })]));
let w = build_warnings(&cfg(), &local, &remote);
assert_eq!(codes(&w), ["gst_account_mismatch"]);
assert!(w[0].message.contains("SGST"));
let mut remote = ic_versions("15.73.0");
remote.gst_accounts = Some(Err("HTTP 403".into()));
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_settings_unreadable"]);
}
#[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": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" }
]}))
} else if path.starts_with("/api/resource/Company?") {
reply(200, json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }))
} 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("INV/2026-001".into()), ..Default::default() };
let result = test_connection(&client(&mock), &cfg(), &local).await.unwrap();
assert_eq!(result.user, "integration@example.com");
assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0"));
assert!(result.india_compliance && result.features.v2_naming);
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("Test Co").await.unwrap();
assert_eq!(options.companies.len(), 1);
assert_eq!(options.companies[0].name, "Test Co");
assert_eq!(options.companies[0].detail, "AC · INR");
assert_eq!(options.company_addresses[0].name, "Test Co-Billing");
assert!(options.company_addresses[0].detail.contains("Mumbai"));
assert_eq!(options.income_accounts[0].name, "Sales - AC");
assert_eq!(options.tax_accounts[0].name, "Output CGST - AC");
assert_eq!(options.naming_series[0].name, "SINV-.YY.-");
assert_eq!(options.uoms.iter().map(|u| u.name.as_str()).collect::<Vec<_>>(), ["Nos", "Hour"]);
assert!(options.price_lists.is_empty());
assert_eq!(options.errors.len(), 1);
assert_eq!(options.errors[0].list, "priceLists");
assert!(options.errors[0].message.contains("No permission for Price List"));
// Company-scoped lists are skipped until a company is chosen.
let none = client(&mock).load_options(" ").await.unwrap();
assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty());
let requests = mock.requests.lock().unwrap().join("\n");
assert!(requests.contains("root_type"), "income accounts are filtered by root type");
assert!(requests.contains("limit_page_length=0"));
}
#[tokio::test]
async fn options_abort_on_authentication_failure() {
let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await;
let err = client(&mock).load_options("Test Co").await.unwrap_err();
assert_eq!(err.kind, ErrorKind::Auth);
assert_eq!(mock.hits.load(std::sync::atomic::Ordering::SeqCst), 1, "stops at the first auth failure");
}
}
@@ -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<u16>,
pub message: String,
/// Frappe exception class (e.g. `DuplicateEntryError`) when the server named one.
pub exc_type: Option<String>,
}
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<ErpError> for String {
fn from(e: ErpError) -> String {
e.to_string()
}
}
impl ErpError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
ErpError { kind, status: None, message: message.into(), exc_type: None }
}
pub fn config(message: impl Into<String>) -> Self {
Self::new(ErrorKind::Config, message)
}
pub fn protocol(message: impl Into<String>) -> 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<String>,
pub exc_type: Option<String>,
}
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::<Value>(body) else {
let text = strip_html(body);
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim();
return ParsedError { message: non_empty(truncate(first)), exc_type: None };
};
let 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<String> = 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<String> {
let Ok(Value::Array(items)) = serde_json::from_str::<Value>(raw) else {
return Vec::new();
};
items
.iter()
.filter_map(|item| {
let text = match item {
Value::String(s) => match serde_json::from_str::<Value>(s) {
Ok(Value::Object(o)) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
_ => 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>, 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<String>, text: String) {
if !text.is_empty() && !list.contains(&text) {
list.push(text);
}
}
fn non_empty(s: String) -> Option<String> {
(!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("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&#x27;", "'")
.replace("&amp;", "&");
decoded
.lines()
.map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.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": "<b>Row 1</b>: Account <i>Sales</i> &amp; more", "indicator": "red" }).to_string();
let inner2 = json!({ "message": "Second <br> 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 <b>permitted</b>" }).to_string());
assert_eq!(a.message.as_deref(), Some("Not permitted"));
let b = parse_error_body(
&json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(),
);
assert_eq!(b.message.as_deref(), Some("Sales Invoice INV/1 already exists"));
assert_eq!(b.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 <b>X</b> 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 = "<html><body><h1>502 Bad Gateway</h1><hr>nginx</body></html>";
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");
}
}
@@ -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<Vendor> {
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<String>],
/// 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::<f64>()
.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<String, String> {
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<TaxType, String> {
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<i64, String> {
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<String> {
let mut used: HashSet<String> = 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<String, Value>, 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<Vec<Value>, 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<Value, String> {
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<Vec<Value>, String> {
match tax_type {
TaxType::None => Ok(Vec::new()),
TaxType::Igst => Ok(vec![tax_row(&cfg.igst_account, "IGST", &scaled_decimal(rate_bp, 2))?]),
TaxType::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<BuiltRequest, String> {
let inv = ctx.invoice;
let cfg = ctx.config;
if inv.status != "issued" {
return Err(format!("Invoice {} is {}; only issued invoices are sent to ERPNext.", inv.number, inv.status));
}
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<BuiltRequest, String> {
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<BuiltRequest, String> {
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: "Test Co".into(),
company_address: "Test 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<String>], submit: bool) -> Result<BuiltRequest, String> {
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": "Test 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": "Test 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": "Test 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": "Test 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());
}
}
+14
View File
@@ -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;
@@ -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<AtomicUsize>,
/// Raw requests (head and body) in arrival order.
pub requests: Arc<Mutex<Vec<String>>>,
}
/// Serves the scripted replies in order; the last one repeats.
pub async fn serve(script: Vec<Reply>) -> Mock {
let script = Arc::new(script);
serve_fn(move |i, _| {
let r = &script[i.min(script.len() - 1)];
Reply { status: r.status, headers: r.headers.clone(), body: r.body.clone(), delay_ms: r.delay_ms }
})
.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::<usize>().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()
}
+38
View File
@@ -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<Output = Result<PushedInvoice, String>> + Send + 'a;
}
+5
View File
@@ -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,
])
+13
View File
@@ -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<BackupStatus>("get_backup_status"),
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
restartApp: () => invoke<void>("restart_app"),
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
erpnextSaveConfig: (config: ErpnextConfigInput) => invoke<ErpnextConfig>("erpnext_save_config", { config }),
erpnextTestConnection: (config: ErpnextConfigInput) =>
invoke<ErpnextConnectionTest>("erpnext_test_connection", { config }),
erpnextLoadOptions: (config: ErpnextConfigInput) => invoke<ErpnextOptions>("erpnext_load_options", { config }),
};
+90
View File
@@ -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<string, string>;
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<ErpnextConfig, "apiSecretSet" | "lastDetectResult"> & {
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 }[];
}