Add ERPNext push logic for invoices and payments (Phase F2)

- 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.
This commit is contained in:
2026-10-04 20:33:45 +05:30
parent e0ba578541
commit 28f922f836
12 changed files with 2356 additions and 18 deletions
+65 -1
View File
@@ -1,11 +1,14 @@
//! 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.
//! 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;
@@ -95,6 +98,67 @@ pub async fn erpnext_load_options(
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::*;