- ErpnextSink implements InvoiceSink. Pushing an issued invoice ensures the Customer and Address (read-back of the returned name, local PIN/state/GSTIN checks), creates the Sales Invoice, optionally submits it in a second call, and attaches the archived PDF as a private file once. - Idempotent: a mirrored-name 409 is compared by grand total (same total records as synced, different total is a conflict and never overwrites); series mode looks up an existing document by its remarks before re-posting; an identical re-push sends nothing. - Create and submit are separate calls so a draft survives a failed submit. A failed push is recorded in erpnext_sync with a readable message. Bulk push never aborts on one failing row. - Payment push requires a submitted parent, maps TDS to Payment Entry deductions and is idempotent through the stored entry name. - Migration M11 rebuilds erpnext_sync to allow a conflict status and adds attachment_sha256. - Commands for single and bulk push, payment push, sync status and the open-in-ERPNext URL, with typed wrappers. No UI yet. Tested against a mock server only. The Payment Entry deductions fields, the series-mode submit route and the payment amount semantics need a live ERPNext check.
218 lines
8.9 KiB
Rust
218 lines
8.9 KiB
Rust
//! 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<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?)
|
|
}
|
|
|
|
/// A client for the saved settings, or a readable reason why there is none.
|
|
fn saved_client(state: &AppState) -> Result<ErpClient, String> {
|
|
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<bool>,
|
|
) -> Result<PushResult, String> {
|
|
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<i64>,
|
|
submit: Option<bool>,
|
|
) -> Result<Vec<PushResult>, 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<PaymentPushResult, String> {
|
|
let http = saved_client(&state)?;
|
|
Ok(push::push_payment(&state.db, &http, payment_id).await)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn erpnext_sync_status(state: State<AppState>, id: i64) -> Result<SyncStatus, String> {
|
|
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<AppState>) -> Result<Vec<SyncStatus>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
push::sync_statuses(&conn)
|
|
}
|
|
|
|
/// `<base>/app/sales-invoice/<name>` for an invoice that was sent.
|
|
#[tauri::command]
|
|
pub fn erpnext_open_url(state: State<AppState>, id: i64) -> Result<String, String> {
|
|
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"]);
|
|
}
|
|
}
|