//! 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. //! The push commands send issued invoices and payments (see `integrations::erpnext::push`); the database lock is //! never held across a network call. 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::integrations::erpnext::push::{self, PaymentPushResult, PushResult}; use crate::integrations::SyncStatus; use crate::AppState; use rusqlite::Connection; use tauri::State; pub fn get_config_impl(conn: &Connection) -> Result { Ok(config::load(conn)?.view()) } /// Merges the form with the stored row (blank secret keeps the stored one) without saving. pub fn resolve_config( conn: &Connection, input: ErpnextConfigInput, ) -> Result { let stored = config::load(conn)?; input.resolve(&stored) } pub fn save_config_impl( conn: &Connection, input: ErpnextConfigInput, ) -> Result { let cfg = resolve_config(conn, input)?; config::save(conn, &cfg)?; Ok(cfg.view()) } /// What the connection test needs from the local database. pub fn local_facts(conn: &Connection) -> Result { let registration: String = conn .query_row( "SELECT gst_registration FROM app_settings WHERE id = 1", [], |r| r.get(0), ) .map_err(|e| e.to_string())?; let mut stmt = conn .prepare("SELECT number FROM invoices ORDER BY id") .map_err(|e| e.to_string())?; let invalid_numbers = stmt .query_map([], |r| r.get::<_, String>(0)) .map_err(|e| e.to_string())? .collect::>>() .map_err(|e| e.to_string())? .into_iter() .filter(|n| !ic_number_ok(n)) .take(5) .collect(); let next_number = conn .query_row( "SELECT prefix, padding, next_number FROM invoice_series WHERE is_active = 1 ORDER BY id DESC LIMIT 1", [], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)), ) .ok() .map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next)); Ok(LocalFacts { vendor_registered: registration != "unregistered", invalid_numbers, next_number, }) } #[tauri::command] pub fn erpnext_get_config(state: State) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; get_config_impl(&conn) } /// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it. #[tauri::command] pub fn erpnext_save_config( state: State, config: ErpnextConfigInput, ) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; save_config_impl(&conn, config) } /// Tests the form values as typed (saved or not). The database lock is released before any network call. #[tauri::command] pub async fn erpnext_test_connection( state: State<'_, AppState>, config: ErpnextConfigInput, ) -> Result { let (cfg, local) = { let conn = state.db.lock().map_err(|e| e.to_string())?; (resolve_config(&conn, config)?, local_facts(&conn)?) }; let client = ErpClient::from_config(&cfg)?; Ok(discovery::test_connection(&client, &cfg, &local).await?) } /// Loads the dropdown lists. Company-scoped lists use `config.company`. #[tauri::command] pub async fn erpnext_load_options( state: State<'_, AppState>, config: ErpnextConfigInput, ) -> Result { let cfg = { let conn = state.db.lock().map_err(|e| e.to_string())?; resolve_config(&conn, config)? }; let client = ErpClient::from_config(&cfg)?; Ok(client.load_options(&cfg.company).await?) } /// A client for the saved settings, or a readable reason why there is none. fn saved_client(state: &AppState) -> Result { let cfg = { let conn = state.db.lock().map_err(|e| e.to_string())?; config::load(&conn)? }; if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() { return Err( "ERPNext is not set up yet: enter the address, API key and API secret in Settings." .into(), ); } Ok(ErpClient::from_config(&cfg)?) } /// Sends one issued invoice. `submit` omitted follows the "submit on push" setting. A failure comes back as a /// result with `ok: false` (and is recorded), not as an `Err`. #[tauri::command] pub async fn erpnext_push_invoice( state: State<'_, AppState>, id: i64, submit: Option, ) -> Result { let http = saved_client(&state)?; Ok(push::push_invoice(&state.db, &state.local_data_dir, &http, id, submit).await) } /// Sends several invoices one after another; one row's failure never stops the others. #[tauri::command] pub async fn erpnext_push_invoices( state: State<'_, AppState>, ids: Vec, submit: Option, ) -> Result, String> { let http = saved_client(&state)?; Ok(push::push_invoices(&state.db, &state.local_data_dir, &http, &ids, submit).await) } #[tauri::command] pub async fn erpnext_push_payment( state: State<'_, AppState>, payment_id: i64, ) -> Result { let http = saved_client(&state)?; Ok(push::push_payment(&state.db, &http, payment_id).await) } #[tauri::command] pub fn erpnext_sync_status(state: State, id: i64) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; push::sync_status(&conn, id) } /// The sync state of every invoice that has one, for the History list. #[tauri::command] pub fn erpnext_sync_statuses(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; push::sync_statuses(&conn) } /// `/app/sales-invoice/` for an invoice that was sent. #[tauri::command] pub fn erpnext_open_url(state: State, id: i64) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; push::open_url(&conn, id) } #[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"]); } }