From d3b5fbb1b8822293a4dddbe5778fcb37c5e1ff41 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 20:33:45 +0530 Subject: [PATCH] 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. --- src-tauri/src/commands/erpnext.rs | 66 +- src-tauri/src/db.rs | 65 +- src-tauri/src/integrations/erpnext/client.rs | 43 +- src-tauri/src/integrations/erpnext/errors.rs | 11 +- src-tauri/src/integrations/erpnext/mapping.rs | 2 +- src-tauri/src/integrations/erpnext/mod.rs | 3 +- src-tauri/src/integrations/erpnext/push.rs | 1147 +++++++++++++++++ .../src/integrations/erpnext/push/tests.rs | 886 +++++++++++++ src-tauri/src/integrations/mod.rs | 65 +- src-tauri/src/lib.rs | 6 + src/lib/api.ts | 16 + src/lib/erpnext.ts | 64 + 12 files changed, 2356 insertions(+), 18 deletions(-) create mode 100644 src-tauri/src/integrations/erpnext/push.rs create mode 100644 src-tauri/src/integrations/erpnext/push/tests.rs diff --git a/src-tauri/src/commands/erpnext.rs b/src-tauri/src/commands/erpnext.rs index ce08a05..bce5492 100644 --- a/src-tauri/src/commands/erpnext.rs +++ b/src-tauri/src/commands/erpnext.rs @@ -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 { + 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::*; diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index a41dbb4..17289b6 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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 = 10; +pub(crate) const LATEST_VERSION: i64 = 11; #[derive(Debug, thiserror::Error)] pub enum DbError { @@ -325,6 +325,26 @@ ALTER TABLE payments ADD COLUMN erpnext_payment_entry TEXT; ALTER TABLE item_presets ADD COLUMN erpnext_item_code TEXT; "#; +/// Version 11: ERPNext push state. `erpnext_sync.status` gains `conflict` (the remote document exists with a different +/// total; we never overwrite it) and `attachment_sha256` records which archived PDF was uploaded so it is never attached +/// twice. SQLite cannot alter a CHECK constraint, so the table is rebuilt; M10 rows are copied over. +const M11: &str = r#" +CREATE TABLE erpnext_sync_new ( + 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', 'conflict')), + last_error TEXT NOT NULL DEFAULT '', + payload_hash TEXT NOT NULL DEFAULT '', + synced_at TEXT, + attachment_sha256 TEXT NOT NULL DEFAULT '' +); +INSERT INTO erpnext_sync_new (invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at) + SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync; +DROP TABLE erpnext_sync; +ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync; +"#; + fn migrations() -> Migrations<'static> { Migrations::new(vec![ M::up(SCHEMA), @@ -337,6 +357,7 @@ fn migrations() -> Migrations<'static> { M::up(M8), M::up(M9), M::up(M10), + M::up(M11), ]) } @@ -889,7 +910,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); .unwrap(); migrations().to_latest(&mut conn).unwrap(); - assert_eq!(user_version(&conn), 10); + assert_eq!(user_version(&conn), LATEST_VERSION); let (name, gstin, customer, address): (String, String, Option, Option) = conn .query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| { @@ -917,6 +938,46 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", []).is_err()); } + #[test] + fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() { + let mut conn = Connection::open_in_memory().unwrap(); + let v10 = 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), M::up(M10), + ]; + Migrations::new(v10).to_latest(&mut conn).unwrap(); + assert_eq!(user_version(&conn), 10); + 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 erpnext_sync (invoice_id, remote_name, remote_docstatus, status, payload_hash, synced_at) + VALUES (1, 'INV/2026-001', 1, 'synced', 'abc', '2026-04-02T00:00:00Z')", + [], + ) + .unwrap(); + assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err()); + + migrations().to_latest(&mut conn).unwrap(); + assert_eq!(user_version(&conn), 11); + let row: (String, i64, String, String, String, Option) = conn + .query_row( + "SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)), + ) + .unwrap(); + assert_eq!( + row, + ("INV/2026-001".into(), 1, "synced".into(), "abc".into(), String::new(), Some("2026-04-02T00:00:00Z".into())) + ); + conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).unwrap(); + assert!(conn.execute("UPDATE erpnext_sync SET status = 'bogus'", []).is_err()); + // The unique key still guards one row per invoice. + assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", []).is_err()); + } + fn build_v1_database(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch(V1_SCHEMA).unwrap(); diff --git a/src-tauri/src/integrations/erpnext/client.rs b/src-tauri/src/integrations/erpnext/client.rs index 56c7ab6..52078ac 100644 --- a/src-tauri/src/integrations/erpnext/client.rs +++ b/src-tauri/src/integrations/erpnext/client.rs @@ -90,6 +90,20 @@ pub fn normalize_base_url(raw: &str) -> Result { Ok(url.as_str().trim_end_matches('/').to_string()) } +/// A file to upload through a multipart POST. +pub struct Upload<'a> { + pub file_name: &'a str, + pub mime: &'a str, + pub bytes: &'a [u8], + pub fields: &'a [(&'a str, String)], +} + +enum Payload<'a> { + None, + Json(&'a Value), + Upload(&'a Upload<'a>), +} + pub struct ErpClient { http: reqwest::Client, base: Url, @@ -171,14 +185,21 @@ impl ErpClient { /// GET, retried on 429, 5xx and timeouts. pub async fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result { - self.send(Method::GET, segments, query, None, true).await + self.send(Method::GET, segments, query, Payload::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 { - self.send(Method::POST, segments, &[], Some(body), idempotent).await + self.send(Method::POST, segments, &[], Payload::Json(body), idempotent).await + } + + /// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file + /// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or + /// timeout, because an upload that may have landed would attach the file twice. + pub async fn post_file(&self, segments: &[&str], upload: &Upload<'_>) -> Result { + self.send(Method::POST, segments, &[], Payload::Upload(upload), false).await } async fn send( @@ -186,7 +207,7 @@ impl ErpClient { method: Method, segments: &[&str], query: &[(&str, String)], - body: Option<&Value>, + payload: Payload<'_>, retry_ambiguous: bool, ) -> Result { let mut attempt: u32 = 0; @@ -195,8 +216,20 @@ impl ErpClient { if !query.is_empty() { req = req.query(query); } - if let Some(b) = body { - req = req.json(b); + match &payload { + Payload::None => {} + Payload::Json(b) => req = req.json(*b), + Payload::Upload(u) => { + let part = reqwest::multipart::Part::bytes(u.bytes.to_vec()) + .file_name(u.file_name.to_string()) + .mime_str(u.mime) + .map_err(|e| ErpError::config(format!("Bad upload type: {e}")))?; + let mut form = reqwest::multipart::Form::new(); + for (k, v) in u.fields { + form = form.text(k.to_string(), v.clone()); + } + req = req.multipart(form.part("file", part)); + } } let can_retry = attempt < self.opts.max_retries; match req.send().await { diff --git a/src-tauri/src/integrations/erpnext/errors.rs b/src-tauri/src/integrations/erpnext/errors.rs index 04edf16..1b668ff 100644 --- a/src-tauri/src/integrations/erpnext/errors.rs +++ b/src-tauri/src/integrations/erpnext/errors.rs @@ -23,6 +23,10 @@ pub enum ErrorKind { Config, /// The server answered, but not like a Frappe site. Protocol, + /// The remote document exists with different data; Voiced never overwrites it. + Conflict, + /// Voiced refused before sending anything (a draft or cancelled invoice, an unsubmitted parent, bad local data). + Precondition, Other, } @@ -36,7 +40,12 @@ impl ErrorKind { ErrorKind::Validation => "ERPNext rejected the data", ErrorKind::RateLimit => "Rate limited", ErrorKind::Server => "ERPNext server error", - ErrorKind::Timeout | ErrorKind::Network | ErrorKind::Config | ErrorKind::Protocol => "", + ErrorKind::Timeout + | ErrorKind::Network + | ErrorKind::Config + | ErrorKind::Protocol + | ErrorKind::Conflict + | ErrorKind::Precondition => "", ErrorKind::Other => "Request failed", } } diff --git a/src-tauri/src/integrations/erpnext/mapping.rs b/src-tauri/src/integrations/erpnext/mapping.rs index b14b193..048f26b 100644 --- a/src-tauri/src/integrations/erpnext/mapping.rs +++ b/src-tauri/src/integrations/erpnext/mapping.rs @@ -91,7 +91,7 @@ fn decimal_number(decimal: &str) -> Value { .unwrap_or_else(|| Value::String(decimal.to_string())) } -fn money(paise: i64) -> Value { +pub(crate) fn money(paise: i64) -> Value { decimal_number(&paise_to_decimal(paise)) } diff --git a/src-tauri/src/integrations/erpnext/mod.rs b/src-tauri/src/integrations/erpnext/mod.rs index e62f691..25a4721 100644 --- a/src-tauri/src/integrations/erpnext/mod.rs +++ b/src-tauri/src/integrations/erpnext/mod.rs @@ -1,7 +1,7 @@ //! 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. +// Parts of this module are only used by the later UI step. #![allow(dead_code)] pub mod client; @@ -9,6 +9,7 @@ pub mod config; pub mod discovery; pub mod errors; pub mod mapping; +pub mod push; #[cfg(test)] pub(crate) mod testutil; diff --git a/src-tauri/src/integrations/erpnext/push.rs b/src-tauri/src/integrations/erpnext/push.rs new file mode 100644 index 0000000..8d5287b --- /dev/null +++ b/src-tauri/src/integrations/erpnext/push.rs @@ -0,0 +1,1147 @@ +//! Pushing issued invoices and their payments to ERPNext. +//! +//! The database mutex is never held across an `await`: each step takes the lock, reads or writes, and +//! releases it before the next network call. Every outcome after the pre-flight checks is recorded in +//! `erpnext_sync` (also failures), and an existing remote document is never overwritten. + +use super::client::{ErpClient, Upload}; +use super::config::{self, ErpnextConfig, NamingMode}; +use super::errors::{ErpError, ErrorKind}; +use super::mapping::{ + self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, InvoiceContext, Vendor, + SALES_INVOICE_V2, +}; +use crate::commands::archive::read_archive_impl; +use crate::commands::invoice::get_invoice_impl; +use crate::gst; +use crate::integrations::{ + InvoiceSink, PaymentRequest, PushRequest, PushedInvoice, PushedPayment, SyncStatus, +}; +use crate::models::{Client, Invoice}; +use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Mutex; + +type Db = Mutex; + +const DOCTYPE_INVOICE: &str = "Sales Invoice"; +const GET_PAYMENT_ENTRY: &str = "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry"; + +fn pre(message: impl Into) -> ErpError { + ErpError::new(ErrorKind::Precondition, message) +} + +fn with_db(db: &Db, f: impl FnOnce(&mut Connection) -> Result) -> Result { + let mut conn = db.lock().map_err(|e| pre(format!("The database is busy: {e}")))?; + f(&mut conn).map_err(|e| pre(format!("Could not read or save the sync state: {e}"))) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +// ---- results ---- + +/// One invoice's push outcome. A failure is a normal result (`ok: false`), so a bulk push can carry on. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PushResult { + pub invoice_id: i64, + pub number: String, + pub ok: bool, + /// `synced`, `error` or `conflict`; `refused` when nothing was sent and nothing was recorded. + pub status: String, + pub remote_name: String, + /// 0 draft, 1 submitted. + pub remote_docstatus: i64, + /// The document was created by this call (not found already there). + pub created: bool, + /// Nothing needed doing: already synced with the same payload. + pub no_op: bool, + /// The archived PDF is attached on the remote document. + pub attached: bool, + pub error: Option, + pub error_kind: Option, + pub warnings: Vec, +} + +impl PushResult { + fn refused(invoice_id: i64, number: &str, e: ErpError) -> Self { + PushResult { + invoice_id, + number: number.to_string(), + ok: false, + status: "refused".into(), + remote_name: String::new(), + remote_docstatus: 0, + created: false, + no_op: false, + attached: false, + error: Some(e.to_string()), + error_kind: Some(e.kind), + warnings: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentPushResult { + pub payment_id: i64, + pub invoice_id: i64, + pub ok: bool, + pub entry_name: Option, + /// The payment had already been sent; nothing was posted. + pub already_synced: bool, + pub error: Option, + pub error_kind: Option, +} + +// ---- local state ---- + +#[derive(Debug, Clone, Default)] +struct SyncRow { + remote_name: String, + remote_docstatus: i64, + status: String, + last_error: String, + payload_hash: String, + synced_at: Option, + attachment_sha256: String, +} + +fn load_sync(conn: &Connection, invoice_id: i64) -> Result, String> { + conn.query_row( + "SELECT remote_name, remote_docstatus, status, last_error, payload_hash, synced_at, attachment_sha256 + FROM erpnext_sync WHERE invoice_id = ?1", + params![invoice_id], + |r| { + Ok(SyncRow { + remote_name: r.get(0)?, + remote_docstatus: r.get(1)?, + status: r.get(2)?, + last_error: r.get(3)?, + payload_hash: r.get(4)?, + synced_at: r.get(5)?, + attachment_sha256: r.get(6)?, + }) + }, + ) + .optional() + .map_err(|e| e.to_string()) +} + +/// One transaction per write, so the row is never half updated. +fn write_sync(conn: &mut Connection, invoice_id: i64, row: &SyncRow) -> Result<(), String> { + let tx = conn.transaction().map_err(|e| e.to_string())?; + tx.execute( + "INSERT INTO erpnext_sync + (invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at, attachment_sha256) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(invoice_id) DO UPDATE SET + remote_name = excluded.remote_name, remote_docstatus = excluded.remote_docstatus, + status = excluded.status, last_error = excluded.last_error, payload_hash = excluded.payload_hash, + synced_at = excluded.synced_at, attachment_sha256 = excluded.attachment_sha256", + params![ + invoice_id, + row.remote_name, + row.remote_docstatus, + row.status, + row.last_error, + row.payload_hash, + row.synced_at, + row.attachment_sha256 + ], + ) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string()) +} + +fn status_of(invoice_id: i64, row: Option) -> SyncStatus { + match row { + None => SyncStatus::none(invoice_id), + Some(r) => SyncStatus { + invoice_id, + status: r.status, + remote_name: r.remote_name, + remote_docstatus: r.remote_docstatus, + last_error: r.last_error, + synced_at: r.synced_at, + attached: !r.attachment_sha256.is_empty(), + }, + } +} + +pub fn sync_status(conn: &Connection, invoice_id: i64) -> Result { + Ok(status_of(invoice_id, load_sync(conn, invoice_id)?)) +} + +/// Every invoice that has a sync row, for the History list. +pub fn sync_statuses(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare("SELECT invoice_id FROM erpnext_sync ORDER BY invoice_id") + .map_err(|e| e.to_string())?; + let ids = stmt + .query_map([], |r| r.get::<_, i64>(0)) + .map_err(|e| e.to_string())? + .collect::>>() + .map_err(|e| e.to_string())?; + ids.into_iter().map(|id| sync_status(conn, id)).collect() +} + +/// `/app/sales-invoice/`, with the name URL-encoded (a mirrored number contains a slash). +pub fn open_url(conn: &Connection, invoice_id: i64) -> Result { + const KEEP: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~'); + let row = load_sync(conn, invoice_id)?; + let name = row.map(|r| r.remote_name).unwrap_or_default(); + if name.is_empty() { + return Err("This invoice has not been sent to ERPNext yet.".into()); + } + let cfg = config::load(conn)?; + let base = super::client::normalize_base_url(&cfg.base_url).map_err(|e| e.to_string())?; + Ok(format!("{base}/app/sales-invoice/{}", utf8_percent_encode(&name, KEEP))) +} + +// ---- loading ---- + +struct ClientRow { + /// `None` when the invoice has no saved client (the details come from the invoice itself). + id: Option, + client: Client, + customer: Option, + address: Option, +} + +struct Pdf { + sha256: String, + bytes: Vec, +} + +struct Loaded { + cfg: ErpnextConfig, + invoice: Invoice, + vendor: Vendor, + client: ClientRow, + item_codes: Vec>, + sync: Option, + pdf: Option, + /// Why there is no PDF to attach although attaching is switched on. + pdf_warning: Option, + india_compliance: bool, +} + +fn load_client(conn: &Connection, inv: &Invoice) -> Result { + if let Some(id) = inv.client_id { + let row = conn + .query_row( + "SELECT name, address, gstin, state_code, po_number, created_at, address_line1, address_line2, city, + pincode, gst_category, default_notes, payment_terms_days, erpnext_customer, erpnext_address + FROM clients WHERE id = ?1", + params![id], + |r| { + let customer: Option = r.get(13)?; + let address: Option = r.get(14)?; + Ok(ClientRow { + id: Some(id), + client: Client { + id: Some(id), + name: r.get(0)?, + address: r.get(1)?, + gstin: r.get(2)?, + state_code: r.get(3)?, + po_number: r.get(4)?, + created_at: r.get(5)?, + address_line1: r.get(6)?, + address_line2: r.get(7)?, + city: r.get(8)?, + pincode: r.get(9)?, + gst_category: r.get(10)?, + default_notes: r.get(11)?, + payment_terms_days: r.get(12)?, + invoice_count: 0, + }, + customer: customer.filter(|c| !c.trim().is_empty()), + address: address.filter(|a| !a.trim().is_empty()), + }) + }, + ) + .optional() + .map_err(|e| e.to_string())?; + if let Some(row) = row { + return Ok(row); + } + } + // No saved client: use what the invoice froze. + let gstin = inv.client_gstin.trim(); + let has_gstin = !gstin.is_empty() && !gstin.eq_ignore_ascii_case("NA"); + Ok(ClientRow { + id: None, + client: Client { + id: None, + name: inv.client_name.clone(), + address: inv.client_address.clone(), + gstin: inv.client_gstin.clone(), + state_code: String::new(), + po_number: String::new(), + created_at: String::new(), + address_line1: String::new(), + address_line2: String::new(), + city: String::new(), + pincode: String::new(), + gst_category: if has_gstin { "registered_regular" } else { "unregistered" }.into(), + default_notes: String::new(), + payment_terms_days: None, + invoice_count: 0, + }, + customer: None, + address: None, + }) +} + +/// Invoice rows are matched to item presets by description (case-insensitive) to find their ERPNext item code. +fn load_item_codes(conn: &Connection, inv: &Invoice) -> Result>, String> { + let mut stmt = conn + .prepare( + "SELECT description, erpnext_item_code FROM item_presets + WHERE erpnext_item_code IS NOT NULL AND trim(erpnext_item_code) <> '' ORDER BY id DESC", + ) + .map_err(|e| e.to_string())?; + let mut by_desc: HashMap = HashMap::new(); + let rows = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) + .map_err(|e| e.to_string())?; + for row in rows { + let (desc, code) = row.map_err(|e| e.to_string())?; + // ORDER BY id DESC: the oldest preset wins on a repeated description. + by_desc.insert(desc.trim().to_lowercase(), code.trim().to_string()); + } + Ok(inv.items.iter().map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned()).collect()) +} + +fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result { + let conn = db + .lock() + .map_err(|e| (String::new(), pre(format!("The database is busy: {e}"))))?; + let invoice = get_invoice_impl(&conn, invoice_id).map_err(|e| (String::new(), pre(e)))?; + let number = invoice.number.clone(); + let fail = |e: ErpError| (number.clone(), e); + match invoice.status.as_str() { + "issued" => {} + "cancelled" => { + return Err(fail(pre(format!( + "Invoice {number} is cancelled, so it is not sent to ERPNext. Cancelled invoices are never pushed." + )))) + } + other => { + return Err(fail(pre(format!( + "Invoice {number} is a {other}; only issued invoices are sent to ERPNext." + )))) + } + } + let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?; + let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot) + .ok_or_else(|| fail(pre(format!("Invoice {number} has no supplier details recorded, so it cannot be sent."))))?; + let client = load_client(&conn, &invoice).map_err(|e| fail(pre(e)))?; + let item_codes = load_item_codes(&conn, &invoice).map_err(|e| fail(pre(e)))?; + let sync = load_sync(&conn, invoice_id).map_err(|e| fail(pre(e)))?; + + let (mut pdf, mut pdf_warning) = (None, None); + if cfg.attach_pdf { + match invoice.archived_pdf_sha256.as_deref() { + None => { + pdf_warning = Some(format!( + "Invoice {number} is not archived, so it was sent without its PDF. Export it once, then push again to attach it." + )) + } + Some(sha) => match read_archive_impl(&conn, local_dir, invoice_id) { + Ok(bytes) => pdf = Some(Pdf { sha256: sha.to_string(), bytes }), + Err(e) => pdf_warning = Some(format!("The PDF could not be attached: {e}.")), + }, + } + } + let india_compliance = serde_json::from_str::(&cfg.last_detect_result) + .ok() + .and_then(|v| v.get("indiaCompliance").and_then(Value::as_bool)) + .unwrap_or(false); + Ok(Loaded { cfg, invoice, vendor, client, item_codes, sync, pdf, pdf_warning, india_compliance }) +} + +// ---- address checks (local, before anything is sent) ---- + +/// First two PIN digits that belong to each GST state code. Deliberately a little generous at the borders +/// (postal circles and states do not line up exactly); it only catches obvious mismatches. +fn pin_prefixes(state_code: &str) -> Option<&'static [&'static str]> { + Some(match state_code { + "01" => &["18", "19"], + "02" => &["17"], + "03" => &["14", "15", "16"], + "04" => &["16"], + "05" => &["24", "25", "26"], + "06" => &["12", "13"], + "07" => &["11"], + "08" => &["30", "31", "32", "33", "34"], + "09" => &["20", "21", "22", "23", "24", "25", "26", "27", "28"], + "10" => &["80", "81", "82", "83", "84", "85"], + "11" => &["73", "75"], + "12" | "13" | "14" | "15" | "16" | "17" => &["79"], + "18" => &["78"], + "19" => &["70", "71", "72", "73", "74"], + "20" => &["81", "82", "83"], + "21" => &["75", "76", "77"], + "22" => &["49"], + "23" => &["45", "46", "47", "48"], + "24" => &["36", "37", "38", "39"], + "26" => &["39"], + "27" => &["40", "41", "42", "43", "44"], + "29" => &["56", "57", "58", "59"], + "30" => &["40"], + "31" => &["68"], + "32" => &["67", "68", "69"], + "33" => &["60", "61", "62", "63", "64"], + "34" => &["53", "60", "67"], + "35" => &["74"], + "36" => &["50", "51", "52"], + "37" => &["50", "51", "52", "53"], + "38" => &["19"], + _ => return None, + }) +} + +fn gstin_applies(client: &Client) -> bool { + let g = client.gstin.trim(); + !g.is_empty() + && !g.eq_ignore_ascii_case("NA") + && matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez") +} + +/// The checks India Compliance would make on an Address, done here so the error is readable and local. +pub fn validate_address(client: &Client) -> Result<(), String> { + let state_code = client.state_code.trim(); + let state_name = mapping::state_name(state_code).unwrap_or("that state"); + let pin = client.pincode.trim(); + if !pin.is_empty() { + if pin.len() != 6 || !pin.bytes().all(|b| b.is_ascii_digit()) || pin.starts_with('0') { + return Err(format!("{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.", client.name.trim())); + } + if let Some(allowed) = pin_prefixes(state_code) { + if !allowed.iter().any(|p| pin.starts_with(p)) { + return Err(format!( + "{}: the PIN code {pin} does not belong to {state_name}. Fix the client's address before sending.", + client.name.trim() + )); + } + } + } + if gstin_applies(client) && !state_code.is_empty() { + let gstin = client.gstin.trim().to_ascii_uppercase(); + if gstin.len() >= 2 && &gstin[0..2] != state_code { + return Err(format!( + "{}: the GSTIN {gstin} starts with state code {}, but the address state is {state_name} ({state_code}).", + client.name.trim(), + &gstin[0..2] + )); + } + } + Ok(()) +} + +// ---- remote steps ---- + +fn doc_name(response: &Value) -> Option { + response + .get("data") + .and_then(|d| d.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(str::to_string) +} + +fn doc_docstatus(doc: &Value) -> i64 { + doc.get("docstatus").and_then(Value::as_i64).unwrap_or(0) +} + +fn doc_total_paise(doc: &Value) -> Option { + let v = doc.get("grand_total")?; + let n = v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?; + Some(gst::rupees_to_paise(n)) +} + +async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result { + if let Some(c) = &l.client.customer { + return Ok(c.clone()); + } + let client = &l.client.client; + let name = client.name.trim(); + let mut found: Option = None; + if l.india_compliance && gstin_applies(client) { + let rows = http + .list_resource( + "Customer", + &["name"], + json!([["gstin", "=", client.gstin.trim().to_ascii_uppercase()]]), + "creation asc", + ) + .await?; + found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string); + } + if found.is_none() { + let rows = http + .list_resource("Customer", &["name"], json!([["customer_name", "=", name]]), "creation asc") + .await?; + found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string); + } + let customer = match found { + Some(c) => c, + None => { + if !l.cfg.create_missing_customers { + return Err(pre(format!( + "The customer \"{name}\" does not exist in ERPNext, and creating customers is switched off in the ERPNext settings." + ))); + } + let req = build_customer(client, &l.cfg, l.india_compliance).map_err(pre)?; + let resp = http.post(req.path, &req.body, req.idempotent).await?; + // A duplicate name comes back as "X - 1": always use what the server returned. + doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new customer's name."))? + } + }; + if let Some(id) = l.client.id { + with_db(db, |c| { + c.execute("UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2", params![customer, id]) + .map(|_| ()) + .map_err(|e| e.to_string()) + })?; + } + Ok(customer) +} + +/// Returns the address name and, when none could be made, a warning. +async fn ensure_address( + db: &Db, + http: &ErpClient, + l: &Loaded, + customer: &str, +) -> Result<(Option, Option), ErpError> { + if let Some(a) = &l.client.address { + return Ok((Some(a.clone()), None)); + } + let client = &l.client.client; + let Some(client_id) = l.client.id else { + return Ok(( + None, + Some("The client is not saved, so the invoice was sent without a customer address.".into()), + )); + }; + let has_address = [&client.address_line1, &client.address, &client.city, &client.pincode] + .iter() + .any(|s| !s.trim().is_empty()); + if !has_address { + return Ok(( + None, + Some(format!("{} has no address saved, so the invoice was sent without a customer address.", client.name.trim())), + )); + } + validate_address(client).map_err(pre)?; + let req = build_address(client, customer, l.india_compliance).map_err(pre)?; + let resp = http.post(req.path, &req.body, req.idempotent).await?; + let name = doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?; + with_db(db, |c| { + c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id]) + .map(|_| ()) + .map_err(|e| e.to_string()) + })?; + Ok((Some(name), None)) +} + +struct RemoteDoc { + name: String, + docstatus: i64, + created: bool, +} + +/// An existing remote document is only accepted when its total equals Voiced's; anything else is a +/// conflict that is reported and never overwritten. +fn accept_existing(name: &str, doc: &Value, inv: &Invoice) -> Result { + let docstatus = doc_docstatus(doc); + if docstatus == 2 { + return Err(ErpError::new( + ErrorKind::Conflict, + format!("ERPNext already has {name} for invoice {}, but it was cancelled there. Resolve it in ERPNext first.", inv.number), + )); + } + let ours = gst::rupees_to_paise(inv.total); + match doc_total_paise(doc) { + Some(theirs) if theirs == ours => Ok(RemoteDoc { name: name.to_string(), docstatus, created: false }), + Some(theirs) => Err(ErpError::new( + ErrorKind::Conflict, + format!( + "ERPNext already has {name} for invoice {}, but its total is {} and Voiced's is {}. Voiced does not overwrite it: fix or delete the ERPNext document, then push again.", + inv.number, + paise_to_decimal(theirs), + paise_to_decimal(ours) + ), + )), + None => Err(ErpError::new( + ErrorKind::Conflict, + format!("ERPNext already has {name} for invoice {}, but its total could not be read to compare.", inv.number), + )), + } +} + +async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result, ErpError> { + let marker = remarks_marker(&l.invoice.number); + let rows = http + .list_resource( + DOCTYPE_INVOICE, + &["name", "docstatus", "grand_total", "remarks"], + json!([["remarks", "like", format!("{marker}%")], ["docstatus", "!=", 2]]), + "creation asc", + ) + .await?; + // "like" is a prefix match: "INV/2026-001" must not pick up "INV/2026-0010". + let hit = rows.iter().find(|r| { + let remarks = r.get("remarks").and_then(Value::as_str).unwrap_or(""); + remarks == marker || remarks.starts_with(&format!("{marker}\n")) + }); + match hit { + Some(doc) => { + let name = doc.get("name").and_then(Value::as_str).unwrap_or_default(); + accept_existing(name, doc, &l.invoice).map(Some) + } + None => Ok(None), + } +} + +async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltRequest) -> Result { + let inv = &l.invoice; + if l.cfg.naming_mode == NamingMode::Series { + if let Some(found) = find_by_remarks(http, l).await? { + return Ok(found); + } + } + match http.post(body.path, &body.body, body.idempotent).await { + Ok(resp) => { + let doc = resp.get("data").cloned().unwrap_or(Value::Null); + let name = match (doc_name(&resp), l.cfg.naming_mode) { + (Some(n), _) => n, + (None, NamingMode::Mirror) => inv.number.clone(), + (None, NamingMode::Series) => { + return Err(ErpError::protocol("ERPNext did not return the new Sales Invoice's name.")) + } + }; + Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true }) + } + Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => { + // The mirrored name is taken: either a repeat of an earlier push or someone else's document. + let mut path: Vec<&str> = SALES_INVOICE_V2.to_vec(); + path.push(&inv.number); + match http.get(&path, &[]).await { + Ok(resp) => { + let doc = resp.get("data").cloned().unwrap_or(Value::Null); + accept_existing(&inv.number, &doc, inv) + } + Err(_) => Err(e), + } + } + Err(e) => Err(e), + } +} + +/// `POST .../method/submit` on API v2 (mirror mode needs v2 anyway); series mode uses the v1 `run_method` form. +/// Never `frappe.client.submit`, which overwrites the whole document. +async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Result { + let resp = match cfg.naming_mode { + NamingMode::Mirror => { + let mut path: Vec<&str> = SALES_INVOICE_V2.to_vec(); + path.extend([name, "method", "submit"]); + http.post(&path, &json!({}), false).await? + } + NamingMode::Series => { + http.post(&["api", "resource", DOCTYPE_INVOICE, name], &json!({ "run_method": "submit" }), false).await? + } + }; + // A 2xx without a docstatus is taken as submitted; one that says otherwise is not. + match resp.get("data").and_then(|d| d.get("docstatus")).and_then(Value::as_i64) { + Some(1) | None => Ok(1), + Some(other) => Err(ErpError::protocol(format!( + "ERPNext accepted the submit request but the document is still at docstatus {other}." + ))), + } +} + +fn attachment_file_name(number: &str) -> String { + let cleaned: String = number + .chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }) + .collect(); + let cleaned = cleaned.trim_matches('-'); + format!("{}.pdf", if cleaned.is_empty() { "invoice" } else { cleaned }) +} + +async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf) -> Result<(), ErpError> { + let file_name = attachment_file_name(&l.invoice.number); + let fields = [ + ("doctype", DOCTYPE_INVOICE.to_string()), + ("docname", remote_name.to_string()), + ("is_private", "1".to_string()), + ]; + let resp = http + .post_file( + &["api", "method", "upload_file"], + &Upload { file_name: &file_name, mime: "application/pdf", bytes: &pdf.bytes, fields: &fields }, + ) + .await?; + let ok = resp.get("message").map(|m| m.get("name").is_some() || m.get("file_url").is_some()).unwrap_or(false); + if ok { + Ok(()) + } else { + Err(ErpError::protocol("ERPNext did not confirm the PDF upload.")) + } +} + +// ---- the invoice push ---- + +#[derive(Default)] +struct Progress { + step: &'static str, + remote_name: String, + remote_docstatus: i64, + payload_hash: String, + attachment_sha256: String, + created: bool, + no_op: bool, + warnings: Vec, +} + +fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, status: &str, error: &str) -> Result<(), ErpError> { + let synced_at = if status == "synced" { + Some(chrono::Utc::now().to_rfc3339()) + } else { + prev.and_then(|p| p.synced_at.clone()) + }; + let row = SyncRow { + remote_name: st.remote_name.clone(), + remote_docstatus: st.remote_docstatus, + status: status.to_string(), + last_error: error.to_string(), + payload_hash: st.payload_hash.clone(), + synced_at, + attachment_sha256: st.attachment_sha256.clone(), + }; + with_db(db, |c| write_sync(c, invoice_id, &row)) +} + +/// The message shown to the user: which step failed, then the error's own readable text. Local refusals and +/// conflicts already say what is wrong and get no prefix. +fn failure_text(step: &str, e: &ErpError) -> String { + if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) { + e.to_string() + } else { + format!("Could not {step}: {e}") + } +} + +async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st: &mut Progress) -> Result<(), ErpError> { + let inv = &l.invoice; + let cfg = &l.cfg; + let prev = l.sync.as_ref(); + // A row with a remote name (synced, or failed after the document was created) is the same remote document; + // a conflict row is re-checked from scratch. + let existing = prev.filter(|s| !s.remote_name.is_empty() && s.status != "conflict"); + if let Some(p) = prev { + st.remote_name = p.remote_name.clone(); + st.remote_docstatus = p.remote_docstatus; + st.payload_hash = p.payload_hash.clone(); + st.attachment_sha256 = p.attachment_sha256.clone(); + } + + st.step = "look up or create the Customer"; + let customer = ensure_customer(db, http, l).await?; + st.step = "create the Address"; + let (address, address_warning) = ensure_address(db, http, l, &customer).await?; + st.warnings.extend(address_warning); + + st.step = ""; + let ctx = InvoiceContext { + invoice: inv, + config: cfg, + vendor: &l.vendor, + customer: &customer, + customer_address: address.as_deref(), + item_codes: &l.item_codes, + india_compliance: l.india_compliance, + submit: false, + }; + let built = build_sales_invoice(&ctx).map_err(pre)?; + // serde_json keeps object keys sorted, so the serialisation (and the hash) is stable. + let hash = sha256_hex(built.body.to_string().as_bytes()); + + match existing { + Some(p) => { + if p.payload_hash.is_empty() { + st.payload_hash = hash; + } else if p.payload_hash != hash { + st.warnings.push( + "The settings or client details changed since this invoice was sent. The ERPNext document was left as it is." + .into(), + ); + } + } + None => { + st.payload_hash = hash; + st.step = "create the Sales Invoice"; + let doc = create_or_find(http, l, &built).await?; + st.remote_name = doc.name; + st.remote_docstatus = doc.docstatus; + st.created = doc.created; + // Keep the remote name even if the next steps fail. + persist(db, inv.id, prev, st, "synced", "")?; + } + } + + let need_submit = want_submit && st.remote_docstatus == 0; + let pdf = l.pdf.as_ref().filter(|p| p.sha256 != st.attachment_sha256); + if let (Some(w), None) = (&l.pdf_warning, &l.pdf) { + st.warnings.push(w.clone()); + } + if existing.is_some() && prev.is_some_and(|p| p.status == "synced") && !need_submit && pdf.is_none() { + st.no_op = true; + return Ok(()); + } + + if need_submit { + st.step = "submit the Sales Invoice"; + st.remote_docstatus = submit_remote(http, cfg, &st.remote_name.clone()).await?; + persist(db, inv.id, prev, st, "synced", "")?; + } + if let Some(pdf) = pdf { + st.step = "attach the PDF"; + match attach_pdf(http, l, &st.remote_name.clone(), pdf).await { + Ok(()) => st.attachment_sha256 = pdf.sha256.clone(), + // The invoice itself is in ERPNext; a failed upload is a warning, retried by the next push. + Err(e) => st.warnings.push(format!("The PDF was not attached: {e}")), + } + } + Ok(()) +} + +pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_id: i64, submit: Option) -> PushResult { + let loaded = match load_for_push(db, local_dir, invoice_id) { + Ok(l) => l, + Err((number, e)) => return PushResult::refused(invoice_id, &number, e), + }; + let want_submit = submit.unwrap_or(loaded.cfg.submit_on_push); + let mut st = Progress::default(); + let outcome = run_push(db, http, &loaded, want_submit, &mut st).await; + let number = loaded.invoice.number.clone(); + let prev = loaded.sync.as_ref(); + + let (error, status): (Option<(ErpError, String)>, &str) = match outcome { + Ok(()) => { + let write = if st.no_op { Ok(()) } else { persist(db, invoice_id, prev, &st, "synced", "") }; + match write { + Ok(()) => (None, "synced"), + Err(e) => { + let text = e.to_string(); + (Some((e, text)), "error") + } + } + } + Err(e) => { + let text = failure_text(st.step, &e); + let status = if e.kind == ErrorKind::Conflict { "conflict" } else { "error" }; + // Best effort: the original error is what the caller needs to see. + let _ = persist(db, invoice_id, prev, &st, status, &text); + (Some((e, text)), status) + } + }; + PushResult { + invoice_id, + number, + ok: error.is_none(), + status: status.to_string(), + remote_name: st.remote_name, + remote_docstatus: st.remote_docstatus, + created: st.created, + no_op: st.no_op, + attached: !st.attachment_sha256.is_empty(), + error: error.as_ref().map(|(_, t)| t.clone()), + error_kind: error.as_ref().map(|(e, _)| e.kind), + warnings: st.warnings, + } +} + +/// Pushes one invoice after another; a failing row never stops the rest. +pub async fn push_invoices( + db: &Db, + local_dir: &Path, + http: &ErpClient, + ids: &[i64], + submit: Option, +) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::with_capacity(ids.len()); + for &id in ids { + if seen.insert(id) { + out.push(push_invoice(db, local_dir, http, id, submit).await); + } + } + out +} + +// ---- payments ---- + +pub struct PaymentEntryInput<'a> { + pub payment_id: i64, + pub invoice_number: &'a str, + pub remote_invoice: &'a str, + pub paid_on: &'a str, + pub reference: &'a str, + /// Cash received. + pub cash_paise: i64, + pub tds_paise: i64, + pub tds_account: &'a str, + pub cost_center: &'a str, +} + +/// Turns the unsaved dict from `get_payment_entry` into the Payment Entry to insert and submit. +/// +/// UNVERIFIED against a live ERPNext (check in F4): the deduction row fields (`account`, `cost_center`, +/// `amount`), the sign ERPNext expects for a TDS deduction on a receipt, and whether `allocated_amount` must be +/// cash plus TDS for the difference amount to come out zero. Everything that depends on those guesses is here. +pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result { + let mut doc: Map = draft.as_object().cloned().ok_or("ERPNext returned an unexpected payment draft.")?; + doc.retain(|k, _| !k.starts_with("__")); + doc.insert("doctype".into(), json!("Payment Entry")); + doc.insert("posting_date".into(), json!(p.paid_on)); + let reference = if p.reference.trim().is_empty() { format!("Voiced payment {}", p.payment_id) } else { p.reference.trim().to_string() }; + doc.insert("reference_no".into(), json!(reference)); + doc.insert("reference_date".into(), json!(p.paid_on)); + doc.insert("paid_amount".into(), mapping::money(p.cash_paise)); + doc.insert("received_amount".into(), mapping::money(p.cash_paise)); + doc.insert("remarks".into(), json!(format!("Voiced payment {} for invoice {}", p.payment_id, p.invoice_number))); + + let allocated = mapping::money(p.cash_paise + p.tds_paise); + let refs = doc.get_mut("references").and_then(Value::as_array_mut).ok_or("ERPNext returned no invoice reference for this payment.")?; + let target = refs + .iter_mut() + .find(|r| r.get("reference_name").and_then(Value::as_str) == Some(p.remote_invoice)) + .ok_or_else(|| format!("ERPNext's payment draft does not reference {}.", p.remote_invoice))?; + target["allocated_amount"] = allocated; + + let deductions = if p.tds_paise > 0 { + let mut row = Map::new(); + row.insert("account".into(), json!(p.tds_account)); + if !p.cost_center.trim().is_empty() { + row.insert("cost_center".into(), json!(p.cost_center.trim())); + } + row.insert("amount".into(), mapping::money(p.tds_paise)); + vec![Value::Object(row)] + } else { + Vec::new() + }; + doc.insert("deductions".into(), Value::Array(deductions)); + doc.insert("docstatus".into(), json!(1)); + Ok(Value::Object(doc)) +} + +struct PaymentRow { + invoice_id: i64, + paid_on: String, + amount_paise: i64, + tds_paise: i64, + reference: String, + entry: Option, +} + +fn payment_failure(payment_id: i64, invoice_id: i64, step: &str, e: ErpError) -> PaymentPushResult { + PaymentPushResult { + payment_id, + invoice_id, + ok: false, + entry_name: None, + already_synced: false, + error: Some(failure_text(step, &e)), + error_kind: Some(e.kind), + } +} + +pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> PaymentPushResult { + let loaded = with_db(db, |conn| { + let row = conn + .query_row( + "SELECT invoice_id, paid_on, amount_paise, tds_paise, reference, erpnext_payment_entry + FROM payments WHERE id = ?1", + params![payment_id], + |r| { + Ok(PaymentRow { + invoice_id: r.get(0)?, + paid_on: r.get(1)?, + amount_paise: r.get(2)?, + tds_paise: r.get(3)?, + reference: r.get(4)?, + entry: r.get::<_, Option>(5)?.filter(|e| !e.trim().is_empty()), + }) + }, + ) + .optional() + .map_err(|e| e.to_string())? + .ok_or_else(|| "Payment not found".to_string())?; + let number: String = conn + .query_row("SELECT number FROM invoices WHERE id = ?1", params![row.invoice_id], |r| r.get(0)) + .map_err(|e| e.to_string())?; + let sync = load_sync(conn, row.invoice_id)?; + let cfg = config::load(conn)?; + Ok((row, number, sync, cfg)) + }); + let (row, number, sync, cfg) = match loaded { + Ok(v) => v, + Err(e) => return payment_failure(payment_id, 0, "", e), + }; + let fail = |e: ErpError| payment_failure(payment_id, row.invoice_id, "", e); + + if let Some(entry) = &row.entry { + return PaymentPushResult { + payment_id, + invoice_id: row.invoice_id, + ok: true, + entry_name: Some(entry.clone()), + already_synced: true, + error: None, + error_kind: None, + }; + } + let remote_invoice = match sync { + // A later unrelated failure may have flipped the row to `error`; the remote document is still submitted. + Some(s) if !s.remote_name.is_empty() && s.status != "conflict" && s.remote_docstatus == 1 => s.remote_name, + Some(s) if !s.remote_name.is_empty() => { + return fail(pre(format!( + "Invoice {number} is not submitted in ERPNext yet. Submit the invoice in ERPNext first (or push it again with \"submit\" on), then send the payment." + ))) + } + _ => { + return fail(pre(format!( + "Invoice {number} has not been sent to ERPNext. Send it first, and submit it, then send the payment." + ))) + } + }; + if cfg.payment_bank_account.trim().is_empty() { + return fail(pre("Set the payment bank account in the ERPNext settings first.")); + } + if row.tds_paise > 0 && cfg.tds_account.trim().is_empty() { + return fail(pre("This payment has TDS: set the TDS account in the ERPNext settings first.")); + } + + let query = [ + ("dt", DOCTYPE_INVOICE.to_string()), + ("dn", remote_invoice.clone()), + ("bank_account", cfg.payment_bank_account.trim().to_string()), + ("party_amount", paise_to_decimal(row.amount_paise + row.tds_paise)), + ]; + let draft = match http.get(&["api", "method", GET_PAYMENT_ENTRY], &query).await { + Ok(v) => v.get("message").cloned().unwrap_or(Value::Null), + Err(e) => return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e), + }; + let body = match build_payment_entry( + &draft, + &PaymentEntryInput { + payment_id, + invoice_number: &number, + remote_invoice: &remote_invoice, + paid_on: &row.paid_on, + reference: &row.reference, + cash_paise: row.amount_paise, + tds_paise: row.tds_paise, + tds_account: cfg.tds_account.trim(), + cost_center: &cfg.cost_center, + }, + ) { + Ok(b) => b, + Err(e) => return fail(ErpError::protocol(e)), + }; + // Not retried after a 5xx or timeout: it may have been created, and a second entry would double-count. + let resp = match http.post(&["api", "resource", "Payment Entry"], &body, false).await { + Ok(v) => v, + Err(e) => return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e), + }; + let Some(name) = doc_name(&resp) else { + return fail(ErpError::protocol("ERPNext did not return the new Payment Entry's name.")); + }; + if let Err(e) = with_db(db, |c| { + c.execute("UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2", params![name, payment_id]) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) { + return fail(e); + } + PaymentPushResult { + payment_id, + invoice_id: row.invoice_id, + ok: true, + entry_name: Some(name), + already_synced: false, + error: None, + error_kind: None, + } +} + +// ---- the sink ---- + +/// `InvoiceSink` for ERPNext. The commands call `push_invoice`/`push_payment` above for the richer results; +/// the trait is the seam other targets implement. +pub struct ErpnextSink<'a> { + pub db: &'a Db, + pub local_dir: &'a Path, + pub http: &'a ErpClient, +} + +impl InvoiceSink for ErpnextSink<'_> { + fn id(&self) -> &'static str { + "erpnext" + } + + fn push_invoice<'a>( + &'a self, + request: PushRequest, + ) -> impl std::future::Future> + Send + 'a { + async move { + let r = push_invoice(self.db, self.local_dir, self.http, request.invoice_id, request.submit).await; + if r.ok { + Ok(PushedInvoice { + remote_name: r.remote_name, + remote_docstatus: r.remote_docstatus, + created: r.created, + warnings: r.warnings, + }) + } else { + Err(r.error.unwrap_or_else(|| "The push failed.".into())) + } + } + } + + fn push_payment<'a>( + &'a self, + request: PaymentRequest, + ) -> impl std::future::Future> + Send + 'a { + async move { + let r = push_payment(self.db, self.http, request.payment_id).await; + match (r.ok, r.entry_name) { + (true, Some(name)) => Ok(PushedPayment { remote_name: name, created: !r.already_synced }), + _ => Err(r.error.unwrap_or_else(|| "The payment push failed.".into())), + } + } + } + + fn status(&self, invoice_id: i64) -> Result { + let conn = self.db.lock().map_err(|e| e.to_string())?; + sync_status(&conn, invoice_id) + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/integrations/erpnext/push/tests.rs b/src-tauri/src/integrations/erpnext/push/tests.rs new file mode 100644 index 0000000..700ea66 --- /dev/null +++ b/src-tauri/src/integrations/erpnext/push/tests.rs @@ -0,0 +1,886 @@ +//! Push tests against the mock HTTP server (no live ERPNext). The mock matches on the request line only, so a +//! route is a `METHOD /path` prefix; the first matching route answers. + +use super::*; +use crate::commands::invoice::{cancel_invoice_impl, issue_invoice_impl}; +use crate::commands::payments::{record_payment_impl, PaymentInput}; +use crate::integrations::erpnext::testutil::{client, serve_fn, Mock, Reply}; +use crate::models::InvoiceInput; +use std::sync::Arc; + +const VENDOR_GSTIN: &str = "27AAPFU0939F1ZV"; +const CLIENT_GSTIN: &str = "29AABCU9603R1ZJ"; + +struct Fx { + db: Db, + dir: tempfile::TempDir, +} + +impl Fx { + fn new(edit: impl FnOnce(&mut ErpnextConfig)) -> Fx { + 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'", + [VENDOR_GSTIN], + ) + .unwrap(); + for (name, gstin, state, line1, city, pin, category) in [ + ("Client Ltd", CLIENT_GSTIN, "29", "12 MG Road", "Bengaluru", "560001", "registered_regular"), + ("Other Co", "", "27", "5 FC Road", "Pune", "411004", "unregistered"), + ] { + conn.execute( + "INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'now')", + params![name, line1, gstin, state, line1, city, pin, category], + ) + .unwrap(); + } + let mut cfg = ErpnextConfig { + base_url: "http://127.0.0.1:8000".into(), + api_key: "thekey".into(), + api_secret: super::super::config::Secret::new("thesecret"), + 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(), + payment_bank_account: "HDFC - AC".into(), + tds_account: "TDS Receivable - AC".into(), + cost_center: "Main - AC".into(), + last_detect_result: r#"{"indiaCompliance":true}"#.into(), + ..Default::default() + }; + edit(&mut cfg); + config::save(&conn, &cfg).unwrap(); + Fx { db: Mutex::new(conn), dir: tempfile::tempdir().unwrap() } + } + + fn issue(&self, client_name: &str) -> Invoice { + let mut conn = self.db.lock().unwrap(); + let (client_id, pos, tax_type, gstin): (i64, &str, &str, &str) = match client_name { + "Client Ltd" => (1, "29", "igst", CLIENT_GSTIN), + _ => (2, "27", "cgst_sgst", ""), + }; + let input: InvoiceInput = serde_json::from_value(json!({ + "invoiceDate": "2026-04-01", + "dueDate": "2026-05-01", + "clientId": client_id, + "clientName": client_name, + "clientGstin": gstin, + "placeOfSupplyStateCode": pos, + "taxType": tax_type, + "taxRate": 18.0, + "items": [{"description": "Design", "mode": "fixed", "amount": 7310.0}], + })) + .unwrap(); + let inv = issue_invoice_impl(&mut conn, self.dir.path(), input, &json!({})).unwrap(); + get_invoice_impl(&conn, inv.id).unwrap() + } + + /// Puts the PDF where `read_archive_impl` expects it and records the hash on the invoice. + fn archive(&self, inv: &Invoice) -> String { + let bytes = b"%PDF-1.4 test invoice".to_vec(); + let sha = sha256_hex(&bytes); + let dir = self.dir.path().join("archive"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(format!("{sha}.pdf")), &bytes).unwrap(); + self.db + .lock() + .unwrap() + .execute("UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", params![sha, inv.id]) + .unwrap(); + sha + } + + fn sync_row(&self, id: i64) -> Option { + load_sync(&self.db.lock().unwrap(), id).unwrap() + } + + fn scalar(&self, sql: &str) -> Option { + self.db.lock().unwrap().query_row(sql, [], |r| r.get::<_, Option>(0)).unwrap() + } + + async fn push(&self, mock: &Mock, id: i64, submit: Option) -> PushResult { + push_invoice(&self.db, self.dir.path(), &client(mock), id, submit).await + } +} + +fn enc(number: &str) -> String { + number.replace('/', "%2F") +} + +type Route = (String, u16, Value); + +fn route(prefix: impl Into, status: u16, body: Value) -> Route { + (prefix.into(), status, body) +} + +async fn mock(routes: Vec) -> Mock { + let routes = Arc::new(routes); + serve_fn(move |_, line| { + match routes.iter().find(|(prefix, _, _)| line.starts_with(prefix.as_str())) { + Some((_, status, body)) => Reply { status: *status, headers: vec![], body: body.to_string(), delay_ms: 0 }, + None => Reply { + status: 404, + headers: vec![], + body: json!({ "exc_type": "DoesNotExistError", "_error_message": "no such route" }).to_string(), + delay_ms: 0, + }, + } + }) + .await +} + +fn hits(m: &Mock, prefix: &str) -> usize { + m.requests.lock().unwrap().iter().filter(|r| r.starts_with(prefix)).count() +} + +fn first_request(m: &Mock, prefix: &str) -> String { + m.requests + .lock() + .unwrap() + .iter() + .find(|r| r.starts_with(prefix)) + .unwrap_or_else(|| panic!("no request starting with {prefix}")) + .clone() +} + +fn json_body(raw: &str) -> Value { + serde_json::from_str(raw.split("\r\n\r\n").nth(1).expect("request body")).expect("JSON body") +} + +const CUSTOMER_GET: &str = "GET /api/resource/Customer?"; +const CUSTOMER_POST: &str = "POST /api/resource/Customer HTTP"; +const ADDRESS_POST: &str = "POST /api/resource/Address HTTP"; +const SI_POST_V2: &str = "POST /api/v2/document/Sales%20Invoice HTTP"; +const SI_POST_V1: &str = "POST /api/resource/Sales%20Invoice HTTP"; +const SI_LIST_V1: &str = "GET /api/resource/Sales%20Invoice?"; +const UPLOAD: &str = "POST /api/method/upload_file HTTP"; + +/// Customer/address routes every push needs (nothing found, then created). +fn master_routes() -> Vec { + vec![ + route(CUSTOMER_GET, 200, json!({ "data": [] })), + route(CUSTOMER_POST, 200, json!({ "data": { "name": "Client Ltd" } })), + route(ADDRESS_POST, 200, json!({ "data": { "name": "Client Ltd-Billing" } })), + route(UPLOAD, 200, json!({ "message": { "name": "f1", "file_url": "/private/files/x.pdf" } })), + ] +} + +fn mirror_create(number: &str, docstatus: i64, total: f64) -> Route { + route(SI_POST_V2, 200, json!({ "data": { "name": number, "docstatus": docstatus, "grand_total": total } })) +} + +// ---- invoices ---- + +#[tokio::test] +async fn draft_push_creates_customer_address_invoice_and_attaches_the_pdf() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let sha = fx.archive(&inv); + let mut routes = master_routes(); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + + let r = fx.push(&m, inv.id, None).await; + assert!(r.ok, "{r:?}"); + assert_eq!((r.status.as_str(), r.remote_name.as_str(), r.remote_docstatus), ("synced", inv.number.as_str(), 0)); + assert!(r.created && r.attached && !r.no_op && r.warnings.is_empty(), "{r:?}"); + assert!(!serde_json::to_string(&r).unwrap().contains("thesecret")); + + // Customer: looked up by GSTIN, then by name, then created; the address is created and linked. + assert_eq!(hits(&m, CUSTOMER_GET), 2); + assert!(first_request(&m, CUSTOMER_POST).contains("\"gstin\":\"29AABCU9603R1ZJ\"")); + let address = json_body(&first_request(&m, ADDRESS_POST)); + assert_eq!(address["state"], "Karnataka"); + assert_eq!(address["links"][0], json!({ "link_doctype": "Customer", "link_name": "Client Ltd" })); + assert_eq!(fx.scalar("SELECT erpnext_customer FROM clients WHERE id = 1").as_deref(), Some("Client Ltd")); + assert_eq!(fx.scalar("SELECT erpnext_address FROM clients WHERE id = 1").as_deref(), Some("Client Ltd-Billing")); + + // The invoice body: draft (no docstatus), customer and address from the created docs. + let body = json_body(&first_request(&m, SI_POST_V2)); + assert_eq!(body["name"], inv.number.as_str()); + assert_eq!(body["customer"], "Client Ltd"); + assert_eq!(body["customer_address"], "Client Ltd-Billing"); + assert!(body.get("docstatus").is_none()); + + // The attachment is a multipart POST: part `file` with the file name, plus doctype/docname/is_private. + let up = first_request(&m, UPLOAD); + assert!(up.to_ascii_lowercase().contains("content-type: multipart/form-data; boundary="), "{up}"); + assert!(up.contains("name=\"file\"; filename=\"INV-2026-001.pdf\"") || up.contains(&format!("filename=\"{}.pdf\"", inv.number.replace('/', "-"))), "{up}"); + assert!(up.contains("Content-Type: application/pdf"), "{up}"); + assert!(up.contains("%PDF-1.4 test invoice")); + assert!(up.contains("name=\"doctype\"\r\n\r\nSales Invoice\r\n"), "{up}"); + assert!(up.contains(&format!("name=\"docname\"\r\n\r\n{}\r\n", inv.number)), "{up}"); + assert!(up.contains("name=\"is_private\"\r\n\r\n1\r\n"), "{up}"); + + let row = fx.sync_row(inv.id).unwrap(); + assert_eq!((row.status.as_str(), row.remote_docstatus, row.last_error.as_str()), ("synced", 0, "")); + assert_eq!(row.attachment_sha256, sha); + assert_eq!(row.payload_hash.len(), 64); + assert!(row.synced_at.is_some()); +} + +#[tokio::test] +async fn submit_flow_and_submitting_a_synced_draft_later_without_recreating_it() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + fx.archive(&inv); + let submit = format!("POST /api/v2/document/Sales%20Invoice/{}/method/submit", enc(&inv.number)); + let mut routes = master_routes(); + routes.push(route(submit.clone(), 200, json!({ "data": { "docstatus": 1 } }))); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + + // Pushed as a draft first (explicit false overrides the setting). + let draft = fx.push(&m, inv.id, Some(false)).await; + assert!(draft.ok && draft.remote_docstatus == 0, "{draft:?}"); + assert_eq!(hits(&m, &submit), 0); + + // Pushing again with submit submits the same document: no second create, no second upload. + let r = fx.push(&m, inv.id, Some(true)).await; + assert!(r.ok && r.remote_docstatus == 1 && !r.created && !r.no_op, "{r:?}"); + assert_eq!(hits(&m, SI_POST_V2), 1); + assert_eq!(hits(&m, &submit), 1); + assert_eq!(hits(&m, UPLOAD), 1); + assert_eq!(fx.sync_row(inv.id).unwrap().remote_docstatus, 1); + + // And a third push is a pure no-op. + let before = m.requests.lock().unwrap().len(); + let again = fx.push(&m, inv.id, Some(true)).await; + assert!(again.ok && again.no_op, "{again:?}"); + assert_eq!(m.requests.lock().unwrap().len(), before); +} + +#[tokio::test] +async fn submit_on_push_setting_is_the_default_and_a_failed_submit_keeps_the_draft_recorded() { + let fx = Fx::new(|c| c.submit_on_push = true); + let inv = fx.issue("Client Ltd"); + let submit = format!("POST /api/v2/document/Sales%20Invoice/{}/method/submit", enc(&inv.number)); + let mut routes = master_routes(); + routes.push(route(submit, 417, json!({ "_error_message": "HSN code is mandatory" }))); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + + let r = fx.push(&m, inv.id, None).await; + assert!(!r.ok && r.status == "error" && r.created, "{r:?}"); + assert!(r.error.as_deref().unwrap().contains("Could not submit the Sales Invoice"), "{r:?}"); + assert!(r.error.as_deref().unwrap().contains("HSN code is mandatory")); + let row = fx.sync_row(inv.id).unwrap(); + assert_eq!((row.status.as_str(), row.remote_name.as_str(), row.remote_docstatus), ("error", inv.number.as_str(), 0)); + assert!(row.last_error.contains("HSN code is mandatory")); +} + +#[tokio::test] +async fn mirrored_409_with_the_same_total_is_recorded_as_synced() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let mut routes = master_routes(); + routes.push(route(SI_POST_V2, 409, json!({ "exc_type": "DuplicateEntryError", "_error_message": "already exists" }))); + routes.push(route( + format!("GET /api/v2/document/Sales%20Invoice/{}", enc(&inv.number)), + 200, + json!({ "data": { "name": inv.number, "docstatus": 1, "grand_total": inv.total } }), + )); + let m = mock(routes).await; + + let r = fx.push(&m, inv.id, Some(false)).await; + assert!(r.ok && !r.created && r.status == "synced" && r.remote_docstatus == 1, "{r:?}"); + let row = fx.sync_row(inv.id).unwrap(); + assert_eq!((row.status.as_str(), row.remote_docstatus), ("synced", 1)); +} + +#[tokio::test] +async fn mirrored_409_with_a_different_total_is_a_conflict_and_nothing_is_overwritten() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let mut routes = master_routes(); + routes.push(route(SI_POST_V2, 409, json!({ "exc_type": "DuplicateEntryError", "_error_message": "already exists" }))); + routes.push(route( + format!("GET /api/v2/document/Sales%20Invoice/{}", enc(&inv.number)), + 200, + json!({ "data": { "name": inv.number, "docstatus": 0, "grand_total": inv.total + 1.0 } }), + )); + let m = mock(routes).await; + + let r = fx.push(&m, inv.id, Some(true)).await; + assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}"); + let msg = r.error.as_deref().unwrap(); + assert!(msg.contains("does not overwrite") && msg.contains(&inv.number), "{msg}"); + let row = fx.sync_row(inv.id).unwrap(); + assert_eq!(row.status, "conflict"); + assert!(row.last_error.contains("does not overwrite")); + // Only the create and the read-back were sent: no PUT, no submit. + assert_eq!(hits(&m, "PUT"), 0); + assert_eq!(hits(&m, "POST /api/v2/document/Sales%20Invoice/"), 0); +} + +#[tokio::test] +async fn series_mode_finds_an_existing_document_by_remarks_instead_of_posting_again() { + let fx = Fx::new(|c| { + c.naming_mode = NamingMode::Series; + c.naming_series = "SINV-.YY.-".into(); + }); + let inv = fx.issue("Client Ltd"); + let mut routes = master_routes(); + routes.push(route( + SI_LIST_V1, + 200, + json!({ "data": [ + // A different invoice whose number merely starts the same way must be ignored. + { "name": "SINV-26-00009", "docstatus": 0, "grand_total": 1.0, "remarks": format!("Voiced invoice {}0", inv.number) }, + { "name": "SINV-26-00001", "docstatus": 0, "grand_total": inv.total, "remarks": format!("Voiced invoice {}\nNet 30", inv.number) }, + ] }), + )); + let m = mock(routes).await; + + let r = fx.push(&m, inv.id, Some(false)).await; + assert!(r.ok && !r.created && r.remote_name == "SINV-26-00001", "{r:?}"); + assert_eq!(hits(&m, SI_POST_V1), 0, "must not create a second document"); + assert_eq!(fx.sync_row(inv.id).unwrap().remote_name, "SINV-26-00001"); +} + +#[tokio::test] +async fn series_mode_creates_with_the_remarks_marker_when_nothing_is_there_yet() { + let fx = Fx::new(|c| { + c.naming_mode = NamingMode::Series; + c.naming_series = "SINV-.YY.-".into(); + }); + let inv = fx.issue("Client Ltd"); + let mut routes = master_routes(); + routes.push(route(SI_LIST_V1, 200, json!({ "data": [] }))); + routes.push(route(SI_POST_V1, 200, json!({ "data": { "name": "SINV-26-00002", "docstatus": 0 } }))); + let submit = "POST /api/resource/Sales%20Invoice/SINV-26-00002 HTTP"; + routes.push(route(submit, 200, json!({ "data": { "docstatus": 1 } }))); + let m = mock(routes).await; + + let r = fx.push(&m, inv.id, Some(true)).await; + assert!(r.ok && r.created && r.remote_name == "SINV-26-00002" && r.remote_docstatus == 1, "{r:?}"); + let body = json_body(&first_request(&m, SI_POST_V1)); + assert_eq!(body["naming_series"], "SINV-.YY.-"); + assert_eq!(body["remarks"], format!("Voiced invoice {}", inv.number)); + assert!(body.get("name").is_none()); + assert_eq!(json_body(&first_request(&m, submit)), json!({ "run_method": "submit" })); + + // A re-push goes by the stored remote name; no lookup, no second create. + let before = hits(&m, SI_POST_V1); + let again = fx.push(&m, inv.id, Some(true)).await; + assert!(again.ok && again.no_op); + assert_eq!(hits(&m, SI_POST_V1), before); +} + +#[tokio::test] +async fn a_duplicate_customer_name_is_read_back_and_used_everywhere() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let mut routes = vec![route(CUSTOMER_POST, 200, json!({ "data": { "name": "Client Ltd - 1" } }))]; + routes.extend(master_routes()); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + + let r = fx.push(&m, inv.id, Some(false)).await; + assert!(r.ok, "{r:?}"); + assert_eq!(json_body(&first_request(&m, SI_POST_V2))["customer"], "Client Ltd - 1"); + assert_eq!(json_body(&first_request(&m, ADDRESS_POST))["links"][0]["link_name"], "Client Ltd - 1"); + assert_eq!(fx.scalar("SELECT erpnext_customer FROM clients WHERE id = 1").as_deref(), Some("Client Ltd - 1")); +} + +#[tokio::test] +async fn an_existing_customer_is_found_not_created_and_creation_can_be_switched_off() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let mut routes = vec![route(CUSTOMER_GET, 200, json!({ "data": [{ "name": "Client Ltd - GST" }] }))]; + routes.extend(master_routes()); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + let r = fx.push(&m, inv.id, Some(false)).await; + assert!(r.ok, "{r:?}"); + assert_eq!(hits(&m, CUSTOMER_POST), 0); + assert_eq!(json_body(&first_request(&m, SI_POST_V2))["customer"], "Client Ltd - GST"); + + let fx = Fx::new(|c| c.create_missing_customers = false); + let inv = fx.issue("Client Ltd"); + let m = mock(master_routes()).await; + let r = fx.push(&m, inv.id, Some(false)).await; + assert!(!r.ok && r.error.as_deref().unwrap().contains("creating customers is switched off"), "{r:?}"); + assert_eq!(hits(&m, CUSTOMER_POST), 0); + assert_eq!(fx.sync_row(inv.id).unwrap().status, "error"); +} + +#[tokio::test] +async fn identical_re_push_of_a_synced_invoice_sends_nothing() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + fx.archive(&inv); + let mut routes = master_routes(); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + + let first = fx.push(&m, inv.id, Some(false)).await; + assert!(first.ok && first.created); + let row = fx.sync_row(inv.id).unwrap(); + let before = m.requests.lock().unwrap().len(); + + let second = fx.push(&m, inv.id, Some(false)).await; + assert!(second.ok && second.no_op && !second.created && second.attached, "{second:?}"); + assert_eq!((second.remote_name.as_str(), second.remote_docstatus), (inv.number.as_str(), 0)); + assert_eq!(m.requests.lock().unwrap().len(), before, "no request at all"); + let after = fx.sync_row(inv.id).unwrap(); + assert_eq!((after.payload_hash, after.synced_at), (row.payload_hash, row.synced_at)); +} + +#[tokio::test] +async fn a_417_is_recorded_in_the_sync_table_with_a_readable_message() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let mut routes = master_routes(); + routes.push(route( + SI_POST_V2, + 417, + json!({ + "exc_type": "ValidationError", + "_server_messages": json!([json!({ "message": "
Row 1: Rate is required
" }).to_string()]).to_string(), + }), + )); + let m = mock(routes).await; + + let r = fx.push(&m, inv.id, Some(false)).await; + assert!(!r.ok && r.status == "error" && r.error_kind == Some(ErrorKind::Validation), "{r:?}"); + let msg = r.error.as_deref().unwrap(); + assert!(msg.starts_with("Could not create the Sales Invoice: ERPNext rejected the data (HTTP 417)"), "{msg}"); + assert!(msg.contains("Row 1: Rate is required") && !msg.contains('<'), "{msg}"); + let row = fx.sync_row(inv.id).unwrap(); + assert_eq!((row.status.as_str(), row.remote_name.as_str()), ("error", "")); + assert_eq!(row.last_error, msg); + assert_eq!(hits(&m, SI_POST_V2), 1, "a validation error is not retried"); +} + +#[tokio::test] +async fn bulk_push_continues_after_a_failing_or_refused_row() { + let fx = Fx::new(|_| {}); + let a = fx.issue("Client Ltd"); + let b = fx.issue("Other Co"); + let cancelled = fx.issue("Client Ltd"); + cancel_invoice_impl(&mut fx.db.lock().unwrap(), cancelled.id, "test").unwrap(); + let d = fx.issue("Client Ltd"); + let mut routes = vec![ + // The customer lookup for "Other Co" is refused; everything else works. + route("GET /api/resource/Customer?fields=%5B%22name%22%5D&limit_page_length=0&order_by=creation+asc&filters=%5B%5B%22customer_name%22%2C%22%3D%22%2C%22Other", + 403, json!({ "exc_type": "PermissionError", "_error_message": "No permission for Customer" })), + ]; + routes.extend(master_routes()); + routes.push(mirror_create("ignored", 0, 0.0)); + // Per-invoice create responses are chosen by the request body, which the mock cannot see; the name falls back + // to the invoice number when the answer carries none. + routes.retain(|(p, _, _)| p != SI_POST_V2); + routes.push(route(SI_POST_V2, 200, json!({ "data": { "docstatus": 0 } }))); + let m = mock(routes).await; + + let out = push_invoices(&fx.db, fx.dir.path(), &client(&m), &[a.id, b.id, cancelled.id, 9999, d.id, a.id], Some(false)).await; + assert_eq!(out.len(), 5, "the repeated id is sent once"); + let oks: Vec = out.iter().map(|r| r.ok).collect(); + assert_eq!(oks, [true, false, false, false, true], "{out:#?}"); + assert_eq!(out[1].status, "error"); + assert!(out[1].error.as_deref().unwrap().contains("Permission denied"), "{:?}", out[1]); + assert_eq!(out[2].status, "refused"); + assert!(out[2].error.as_deref().unwrap().contains("cancelled")); + assert_eq!(out[3].status, "refused"); + assert_eq!(out[4].remote_name, d.number); + assert!(fx.sync_row(b.id).is_some_and(|r| r.status == "error")); + assert!(fx.sync_row(cancelled.id).is_none(), "a refusal records nothing"); +} + +#[tokio::test] +async fn cancelled_and_draft_invoices_are_refused_before_any_request() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + cancel_invoice_impl(&mut fx.db.lock().unwrap(), inv.id, "wrong client").unwrap(); + fx.db + .lock() + .unwrap() + .execute( + "INSERT INTO invoices (number, invoice_date, status, created_at, updated_at) VALUES ('DRAFT-1', '2026-04-01', 'draft', 'n', 'n')", + [], + ) + .unwrap(); + let draft_id: i64 = fx.db.lock().unwrap().query_row("SELECT id FROM invoices WHERE number = 'DRAFT-1'", [], |r| r.get(0)).unwrap(); + let m = mock(master_routes()).await; + + let r = fx.push(&m, inv.id, None).await; + assert!(!r.ok && r.status == "refused" && r.error_kind == Some(ErrorKind::Precondition)); + assert!(r.error.as_deref().unwrap().contains("cancelled"), "{r:?}"); + let d = fx.push(&m, draft_id, None).await; + assert!(!d.ok && d.status == "refused" && d.error.as_deref().unwrap().contains("draft"), "{d:?}"); + assert!(m.requests.lock().unwrap().is_empty()); + assert!(fx.sync_row(inv.id).is_none() && fx.sync_row(draft_id).is_none()); +} + +#[tokio::test] +async fn an_unarchived_invoice_is_pushed_without_a_pdf_and_attached_by_a_later_push() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let mut routes = master_routes(); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + + let r = fx.push(&m, inv.id, Some(false)).await; + assert!(r.ok && !r.attached, "{r:?}"); + assert!(r.warnings.iter().any(|w| w.contains("not archived")), "{:?}", r.warnings); + assert_eq!(hits(&m, UPLOAD), 0); + assert_eq!(fx.sync_row(inv.id).unwrap().attachment_sha256, ""); + + // Once archived, the next push attaches it (and still does not create the invoice again). + fx.archive(&inv); + let again = fx.push(&m, inv.id, Some(false)).await; + assert!(again.ok && again.attached && !again.no_op && !again.created, "{again:?}"); + assert_eq!(hits(&m, UPLOAD), 1); + assert_eq!(hits(&m, SI_POST_V2), 1); +} + +#[tokio::test] +async fn pdf_attachment_can_be_switched_off_and_a_failed_upload_is_only_a_warning() { + let fx = Fx::new(|c| c.attach_pdf = false); + let inv = fx.issue("Client Ltd"); + fx.archive(&inv); + let mut routes = master_routes(); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + let r = fx.push(&m, inv.id, Some(false)).await; + assert!(r.ok && !r.attached && r.warnings.is_empty(), "{r:?}"); + assert_eq!(hits(&m, UPLOAD), 0); + + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + fx.archive(&inv); + let mut routes = vec![route(UPLOAD, 403, json!({ "_error_message": "No permission for File" }))]; + routes.extend(master_routes()); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + let r = fx.push(&m, inv.id, Some(false)).await; + assert!(r.ok && !r.attached, "{r:?}"); + assert!(r.warnings.iter().any(|w| w.contains("The PDF was not attached") && w.contains("No permission for File")), "{:?}", r.warnings); + let row = fx.sync_row(inv.id).unwrap(); + assert_eq!((row.status.as_str(), row.attachment_sha256.as_str()), ("synced", "")); +} + +#[tokio::test] +async fn local_address_problems_stop_the_push_before_any_address_is_sent() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + fx.db.lock().unwrap().execute("UPDATE clients SET pincode = '411004' WHERE id = 1", []).unwrap(); + let mut routes = master_routes(); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + + let r = fx.push(&m, inv.id, Some(false)).await; + assert!(!r.ok && r.error_kind == Some(ErrorKind::Precondition), "{r:?}"); + assert!(r.error.as_deref().unwrap().contains("PIN code 411004 does not belong to Karnataka"), "{r:?}"); + assert_eq!(hits(&m, ADDRESS_POST), 0); + assert_eq!(fx.sync_row(inv.id).unwrap().status, "error"); +} + +#[test] +fn address_validation_checks_pin_state_and_gstin_prefix() { + let mut c: Client = serde_json::from_value(json!({ + "name": "Client Ltd", "gstin": CLIENT_GSTIN, "stateCode": "29", "pincode": "560001", + "gstCategory": "registered_regular", + })) + .unwrap(); + assert!(validate_address(&c).is_ok()); + c.pincode = "56001".into(); + assert!(validate_address(&c).unwrap_err().contains("not a valid 6-digit PIN")); + c.pincode = "411004".into(); + assert!(validate_address(&c).unwrap_err().contains("does not belong to Karnataka")); + c.pincode = "411004".into(); + c.state_code = "27".into(); + let err = validate_address(&c).unwrap_err(); + assert!(err.contains("starts with state code 29") && err.contains("Maharashtra"), "{err}"); + // An unregistered client's GSTIN field is ignored, and an empty PIN is allowed. + c.gst_category = "unregistered".into(); + c.pincode.clear(); + assert!(validate_address(&c).is_ok()); + // Every GST state code with a PIN table accepts one of its own prefixes and refuses a foreign one. + for (code, _) in gst::STATES { + if let Some(prefixes) = pin_prefixes(code) { + let mut x = c.clone(); + x.gst_category = "unregistered".into(); + x.state_code = code.to_string(); + x.pincode = format!("{}0001", prefixes[0]); + assert!(validate_address(&x).is_ok(), "{code}"); + } + } +} + +#[test] +fn attachment_names_are_sanitised() { + assert_eq!(attachment_file_name("INV/2026-001"), "INV-2026-001.pdf"); + assert_eq!(attachment_file_name("a b\\c"), "a-b-c.pdf"); + assert_eq!(attachment_file_name("///"), "invoice.pdf"); +} + +#[test] +fn sync_status_list_and_open_url() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let conn = fx.db.lock().unwrap(); + assert_eq!(sync_status(&conn, inv.id).unwrap().status, "none"); + assert!(sync_statuses(&conn).unwrap().is_empty()); + assert!(open_url(&conn, inv.id).unwrap_err().contains("not been sent")); + drop(conn); + + let mut conn = fx.db.lock().unwrap(); + write_sync( + &mut conn, + inv.id, + &SyncRow { + remote_name: "INV/2026-001".into(), + remote_docstatus: 1, + status: "synced".into(), + attachment_sha256: "abc".into(), + synced_at: Some("2026-04-02T00:00:00Z".into()), + ..Default::default() + }, + ) + .unwrap(); + let s = sync_status(&conn, inv.id).unwrap(); + assert_eq!((s.status.as_str(), s.remote_name.as_str(), s.remote_docstatus, s.attached), ("synced", "INV/2026-001", 1, true)); + assert_eq!(sync_statuses(&conn).unwrap(), vec![s]); + assert_eq!(open_url(&conn, inv.id).unwrap(), "http://127.0.0.1:8000/app/sales-invoice/AP%2F2026-001"); + // Names with spaces and other characters stay one path segment. + conn.execute("UPDATE erpnext_sync SET remote_name = 'SINV 26/1?x' WHERE invoice_id = ?1", params![inv.id]).unwrap(); + assert_eq!(open_url(&conn, inv.id).unwrap(), "http://127.0.0.1:8000/app/sales-invoice/SINV%2026%2F1%3Fx"); +} + +// ---- payments ---- + +fn payment_draft(remote_invoice: &str) -> Value { + json!({ + "doctype": "Payment Entry", + "payment_type": "Receive", + "party_type": "Customer", + "party": "Client Ltd", + "posting_date": "2026-09-09", + "paid_amount": 1100.0, + "received_amount": 1100.0, + "__onload": { "x": 1 }, + "references": [ + { "reference_doctype": "Sales Invoice", "reference_name": "OTHER", "allocated_amount": 5.0 }, + { "reference_doctype": "Sales Invoice", "reference_name": remote_invoice, "allocated_amount": 1100.0, "total_amount": 8625.8 }, + ], + "deductions": [], + }) +} + +const PE_GET: &str = "GET /api/method/erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry?"; +const PE_POST: &str = "POST /api/resource/Payment%20Entry HTTP"; + +fn mark_invoice_synced(fx: &Fx, inv: &Invoice, docstatus: i64) { + write_sync( + &mut fx.db.lock().unwrap(), + inv.id, + &SyncRow { remote_name: inv.number.clone(), remote_docstatus: docstatus, status: "synced".into(), ..Default::default() }, + ) + .unwrap(); +} + +fn add_payment(fx: &Fx, inv: &Invoice, cash: i64, tds: i64) -> i64 { + record_payment_impl( + &mut fx.db.lock().unwrap(), + PaymentInput { + invoice_id: inv.id, + paid_on: "2026-04-15".into(), + amount_paise: cash, + tds_paise: tds, + mode: "bank_transfer".into(), + reference: "UTR123".into(), + notes: String::new(), + }, + ) + .unwrap() + .id +} + +#[tokio::test] +async fn payments_need_a_synced_and_submitted_parent_invoice() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let pid = add_payment(&fx, &inv, 100_000, 10_000); + let m = mock(vec![]).await; + + let r = push_payment(&fx.db, &client(&m), pid).await; + assert!(!r.ok && r.error_kind == Some(ErrorKind::Precondition), "{r:?}"); + assert!(r.error.as_deref().unwrap().contains("has not been sent to ERPNext"), "{r:?}"); + + mark_invoice_synced(&fx, &inv, 0); + let r = push_payment(&fx.db, &client(&m), pid).await; + assert!(!r.ok, "{r:?}"); + assert!(r.error.as_deref().unwrap().to_lowercase().contains("submit the invoice in erpnext first"), "{r:?}"); + assert!(m.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn payment_with_tds_maps_deductions_and_is_idempotent() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + mark_invoice_synced(&fx, &inv, 1); + let pid = add_payment(&fx, &inv, 100_000, 10_000); + let m = mock(vec![ + route(PE_GET, 200, json!({ "message": payment_draft(&inv.number) })), + route(PE_POST, 200, json!({ "data": { "name": "ACC-PAY-2026-00001" } })), + ]) + .await; + + let r = push_payment(&fx.db, &client(&m), pid).await; + assert!(r.ok && !r.already_synced && r.entry_name.as_deref() == Some("ACC-PAY-2026-00001"), "{r:?}"); + + let get = first_request(&m, PE_GET); + for part in ["dt=Sales+Invoice", &format!("dn={}", inv.number.replace('/', "%2F")), "bank_account=HDFC+-+AC", "party_amount=1100.00"] { + assert!(get.lines().next().unwrap().contains(part), "{part} in {get}"); + } + let body = json_body(&first_request(&m, PE_POST)); + assert_eq!(body["docstatus"], 1); + assert_eq!(body["posting_date"], "2026-04-15"); + assert_eq!(body["reference_no"], "UTR123"); + assert_eq!(body["reference_date"], "2026-04-15"); + assert_eq!(body["paid_amount"], json!(1000.0)); + assert_eq!(body["received_amount"], json!(1000.0)); + assert_eq!(body["references"][1]["allocated_amount"], json!(1100.0)); + assert_eq!(body["references"][0]["allocated_amount"], json!(5.0), "other references are left alone"); + assert_eq!(body["deductions"], json!([{ "account": "TDS Receivable - AC", "cost_center": "Main - AC", "amount": 100.0 }])); + assert!(body.get("__onload").is_none()); + assert_eq!( + fx.scalar(&format!("SELECT erpnext_payment_entry FROM payments WHERE id = {pid}")).as_deref(), + Some("ACC-PAY-2026-00001") + ); + + let before = m.requests.lock().unwrap().len(); + let again = push_payment(&fx.db, &client(&m), pid).await; + assert!(again.ok && again.already_synced && again.entry_name.as_deref() == Some("ACC-PAY-2026-00001")); + assert_eq!(m.requests.lock().unwrap().len(), before); +} + +#[tokio::test] +async fn tds_account_is_required_only_for_payments_with_tds_and_the_bank_always() { + let fx = Fx::new(|c| c.tds_account.clear()); + let inv = fx.issue("Client Ltd"); + mark_invoice_synced(&fx, &inv, 1); + let with_tds = add_payment(&fx, &inv, 100_000, 10_000); + let without = add_payment(&fx, &inv, 200_000, 0); + let m = mock(vec![ + route(PE_GET, 200, json!({ "message": payment_draft(&inv.number) })), + route(PE_POST, 200, json!({ "data": { "name": "ACC-PAY-1" } })), + ]) + .await; + + let r = push_payment(&fx.db, &client(&m), with_tds).await; + assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS account"), "{r:?}"); + assert!(m.requests.lock().unwrap().is_empty()); + + let r = push_payment(&fx.db, &client(&m), without).await; + assert!(r.ok, "{r:?}"); + assert_eq!(json_body(&first_request(&m, PE_POST))["deductions"], json!([])); + + fx.db.lock().unwrap().execute("UPDATE erpnext_config SET payment_bank_account = ''", []).unwrap(); + let r = push_payment(&fx.db, &client(&m), with_tds).await; + assert!(!r.ok && r.error.as_deref().unwrap().contains("payment bank account"), "{r:?}"); +} + +#[tokio::test] +async fn payment_errors_come_back_readable_and_leave_the_payment_unlinked() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + mark_invoice_synced(&fx, &inv, 1); + let pid = add_payment(&fx, &inv, 100_000, 0); + let m = mock(vec![ + route(PE_GET, 200, json!({ "message": payment_draft(&inv.number) })), + route(PE_POST, 417, json!({ "_error_message": "Reference No and Reference Date is mandatory for Bank transaction" })), + ]) + .await; + let r = push_payment(&fx.db, &client(&m), pid).await; + assert!(!r.ok && r.error_kind == Some(ErrorKind::Validation), "{r:?}"); + assert!(r.error.as_deref().unwrap().starts_with("Could not create the Payment Entry:"), "{r:?}"); + assert!(r.error.as_deref().unwrap().contains("Reference No and Reference Date")); + assert_eq!(fx.scalar(&format!("SELECT erpnext_payment_entry FROM payments WHERE id = {pid}")), None); +} + +#[test] +fn payment_entry_mapping_is_pure_and_covers_the_unverified_deduction_fields() { + let input = |tds: i64, reference: &'static str| PaymentEntryInput { + payment_id: 7, + invoice_number: "INV/2026-001", + remote_invoice: "INV/2026-001", + paid_on: "2026-04-15", + reference, + cash_paise: 90_050, + tds_paise: tds, + tds_account: "TDS Receivable - AC", + cost_center: "", + }; + let draft = payment_draft("INV/2026-001"); + let doc = build_payment_entry(&draft, &input(9_950, "")).unwrap(); + assert_eq!(doc["reference_no"], "Voiced payment 7", "a blank reference gets a placeholder; ERPNext requires one"); + assert_eq!(doc["paid_amount"].to_string(), "900.5"); + assert_eq!(doc["references"][1]["allocated_amount"].to_string(), "1000.0", "cash plus TDS is allocated"); + assert_eq!(doc["deductions"], json!([{ "account": "TDS Receivable - AC", "amount": 99.5 }])); + assert_eq!(doc["remarks"], "Voiced payment 7 for invoice INV/2026-001"); + + let no_tds = build_payment_entry(&draft, &input(0, "UTR9")).unwrap(); + assert_eq!(no_tds["deductions"], json!([])); + assert_eq!(no_tds["references"][1]["allocated_amount"].to_string(), "900.5"); + + // A draft that does not reference the invoice, or is not an object, is refused. + let other = payment_draft("SOMETHING-ELSE"); + assert!(build_payment_entry(&other, &input(0, "x")).unwrap_err().contains("does not reference")); + assert!(build_payment_entry(&json!([]), &input(0, "x")).is_err()); + assert!(build_payment_entry(&json!({ "doctype": "Payment Entry" }), &input(0, "x")).is_err()); +} + +// ---- the sink ---- + +#[tokio::test] +async fn the_sink_wraps_the_push_and_reports_local_status() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let mut routes = master_routes(); + routes.push(mirror_create(&inv.number, 0, inv.total)); + let m = mock(routes).await; + let http = client(&m); + let sink = ErpnextSink { db: &fx.db, local_dir: fx.dir.path(), http: &http }; + + assert_eq!(sink.id(), "erpnext"); + assert_eq!(sink.status(inv.id).unwrap().status, "none"); + let pushed = sink.push_invoice(PushRequest { invoice_id: inv.id, submit: Some(false) }).await.unwrap(); + assert_eq!((pushed.remote_name.as_str(), pushed.remote_docstatus, pushed.created), (inv.number.as_str(), 0, true)); + assert_eq!(sink.status(inv.id).unwrap().status, "synced"); + // A payment on a draft parent is an Err with the readable reason. + let pid = add_payment(&fx, &inv, 100_000, 0); + let err = sink.push_payment(PaymentRequest { payment_id: pid }).await.unwrap_err(); + assert!(err.to_lowercase().contains("submit the invoice in erpnext first"), "{err}"); +} + +#[tokio::test] +async fn a_submit_answered_with_a_draft_is_not_recorded_as_submitted_and_payments_survive_a_later_error() { + let fx = Fx::new(|_| {}); + let inv = fx.issue("Client Ltd"); + let submit = format!("POST /api/v2/document/Sales%20Invoice/{}/method/submit", enc(&inv.number)); + let mut routes = master_routes(); + routes.push(route(submit, 200, json!({ "data": { "docstatus": 0 } }))); + routes.push(mirror_create(&inv.number, 0, inv.total)); + routes.push(route(PE_GET, 200, json!({ "message": payment_draft(&inv.number) }))); + routes.push(route(PE_POST, 200, json!({ "data": { "name": "ACC-PAY-1" } }))); + let m = mock(routes).await; + let r = fx.push(&m, inv.id, Some(true)).await; + assert!(!r.ok && r.remote_docstatus == 0 && r.error.as_deref().unwrap().contains("still at docstatus 0"), "{r:?}"); + assert_eq!(fx.sync_row(inv.id).unwrap().remote_docstatus, 0); + + // A submitted invoice whose row later flipped to `error` still accepts payments. + let pid = add_payment(&fx, &inv, 100_000, 0); + fx.db.lock().unwrap().execute("UPDATE erpnext_sync SET remote_docstatus = 1, status = 'error'", []).unwrap(); + assert!(push_payment(&fx.db, &client(&m), pid).await.ok); + fx.db.lock().unwrap().execute("UPDATE erpnext_sync SET status = 'conflict'", []).unwrap(); + let pid2 = add_payment(&fx, &inv, 100_000, 0); + assert!(!push_payment(&fx.db, &client(&m), pid2).await.ok); +} diff --git a/src-tauri/src/integrations/mod.rs b/src-tauri/src/integrations/mod.rs index bcff5ea..237d662 100644 --- a/src-tauri/src/integrations/mod.rs +++ b/src-tauri/src/integrations/mod.rs @@ -2,12 +2,12 @@ //! 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. +// Parts of the sink API are only used by the later UI step. #![allow(dead_code)] pub mod erpnext; -use crate::models::Invoice; +use serde::Serialize; use std::future::Future; /// What a sink reports after accepting an invoice. @@ -19,12 +19,55 @@ pub struct PushedInvoice { pub remote_docstatus: i64, /// False when the remote already had this invoice (an idempotent re-push). pub created: bool, + /// Non-fatal notes, e.g. "pushed without the PDF because the invoice is not archived". + pub warnings: Vec, } -pub struct PushRequest<'a> { - pub invoice: &'a Invoice, - /// Submit on the remote system instead of leaving a draft for review. - pub submit: bool, +pub struct PushRequest { + pub invoice_id: i64, + /// Submit on the remote system instead of leaving a draft for review; `None` follows the sink's setting. + pub submit: Option, +} + +pub struct PaymentRequest { + pub payment_id: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PushedPayment { + pub remote_name: String, + /// False when the payment had already been sent (an idempotent re-push). + pub created: bool, +} + +/// The locally recorded sync state of one invoice. `status` is `none` until something was pushed, then +/// `synced`, `error` or `conflict`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncStatus { + pub invoice_id: i64, + pub status: String, + pub remote_name: String, + /// 0 draft, 1 submitted. + pub remote_docstatus: i64, + pub last_error: String, + pub synced_at: Option, + /// The archived PDF was uploaded to the remote document. + pub attached: bool, +} + +impl SyncStatus { + pub fn none(invoice_id: i64) -> Self { + SyncStatus { + invoice_id, + status: "none".into(), + remote_name: String::new(), + remote_docstatus: 0, + last_error: String::new(), + synced_at: None, + attached: false, + } + } } pub trait InvoiceSink: Send + Sync { @@ -33,6 +76,14 @@ pub trait InvoiceSink: Send + Sync { fn push_invoice<'a>( &'a self, - request: PushRequest<'a>, + request: PushRequest, ) -> impl Future> + Send + 'a; + + fn push_payment<'a>( + &'a self, + request: PaymentRequest, + ) -> impl Future> + Send + 'a; + + /// The locally recorded state; never touches the network. + fn status(&self, invoice_id: i64) -> Result; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ceab8c8..9f357f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -181,6 +181,12 @@ pub fn run() { commands::erpnext::erpnext_save_config, commands::erpnext::erpnext_test_connection, commands::erpnext::erpnext_load_options, + commands::erpnext::erpnext_push_invoice, + commands::erpnext::erpnext_push_invoices, + commands::erpnext::erpnext_push_payment, + commands::erpnext::erpnext_sync_status, + commands::erpnext::erpnext_sync_statuses, + commands::erpnext::erpnext_open_url, selftest::selftest_config, selftest::selftest_report, ]) diff --git a/src/lib/api.ts b/src/lib/api.ts index 9e1997e..d99d8b9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -19,6 +19,9 @@ import type { ErpnextConfigInput, ErpnextConnectionTest, ErpnextOptions, + ErpnextPaymentPushResult, + ErpnextPushResult, + ErpnextSyncStatus, } from "./erpnext"; import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport"; @@ -126,4 +129,17 @@ export const api = { erpnextTestConnection: (config: ErpnextConfigInput) => invoke("erpnext_test_connection", { config }), erpnextLoadOptions: (config: ErpnextConfigInput) => invoke("erpnext_load_options", { config }), + /** `submit` omitted follows the "submit on push" setting. A failed push resolves with `ok: false`. */ + erpnextPushInvoice: (id: number, submit?: boolean) => + invoke("erpnext_push_invoice", { id, submit: submit ?? null }), + /** One result per distinct id, in order; a failing row never stops the rest. */ + erpnextPushInvoices: (ids: number[], submit?: boolean) => + invoke("erpnext_push_invoices", { ids, submit: submit ?? null }), + erpnextPushPayment: (paymentId: number) => + invoke("erpnext_push_payment", { paymentId }), + erpnextSyncStatus: (id: number) => invoke("erpnext_sync_status", { id }), + /** Every invoice that has a sync row (invoices never pushed are absent). */ + erpnextSyncStatuses: () => invoke("erpnext_sync_statuses"), + /** `/app/sales-invoice/`; rejects when the invoice was never sent. */ + erpnextOpenUrl: (id: number) => invoke("erpnext_open_url", { id }), }; diff --git a/src/lib/erpnext.ts b/src/lib/erpnext.ts index 0e66891..6fe3ade 100644 --- a/src/lib/erpnext.ts +++ b/src/lib/erpnext.ts @@ -88,3 +88,67 @@ export interface ErpnextOptions { /** Lists that failed to load; the others are still filled. */ errors: { list: string; message: string }[]; } + +export type ErpnextErrorKind = + | "auth" + | "permission" + | "not_found" + | "duplicate" + | "validation" + | "rate_limit" + | "server" + | "timeout" + | "network" + | "config" + | "protocol" + | "conflict" + | "precondition" + | "other"; + +/** `refused`: nothing was sent and nothing was recorded (a draft or cancelled invoice). */ +export type ErpnextPushStatus = "synced" | "error" | "conflict" | "refused"; + +export interface ErpnextPushResult { + invoiceId: number; + number: string; + ok: boolean; + status: ErpnextPushStatus; + remoteName: string; + /** 0 draft, 1 submitted. */ + remoteDocstatus: number; + /** Created by this call, not found already there. */ + created: boolean; + /** Already synced with the same payload; nothing was done. */ + noOp: boolean; + /** The archived PDF is attached on the remote document. */ + attached: boolean; + error: string | null; + errorKind: ErpnextErrorKind | null; + /** Non-fatal notes, e.g. sent without the PDF because the invoice is not archived. */ + warnings: string[]; +} + +export interface ErpnextPaymentPushResult { + paymentId: number; + invoiceId: number; + ok: boolean; + entryName: string | null; + /** Sent earlier; nothing was posted. */ + alreadySynced: boolean; + error: string | null; + errorKind: ErpnextErrorKind | null; +} + +/** `none` until something was pushed. */ +export type ErpnextSyncState = "none" | "pending" | "synced" | "error" | "conflict"; + +export interface ErpnextSyncStatus { + invoiceId: number; + status: ErpnextSyncState; + remoteName: string; + /** 0 draft, 1 submitted. */ + remoteDocstatus: number; + lastError: string; + syncedAt: string | null; + attached: boolean; +}