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