Add ERPNext push logic for invoices and payments (Phase F2)
- ErpnextSink implements InvoiceSink. Pushing an issued invoice ensures the Customer and Address (read-back of the returned name, local PIN/state/GSTIN checks), creates the Sales Invoice, optionally submits it in a second call, and attaches the archived PDF as a private file once. - Idempotent: a mirrored-name 409 is compared by grand total (same total records as synced, different total is a conflict and never overwrites); series mode looks up an existing document by its remarks before re-posting; an identical re-push sends nothing. - Create and submit are separate calls so a draft survives a failed submit. A failed push is recorded in erpnext_sync with a readable message. Bulk push never aborts on one failing row. - Payment push requires a submitted parent, maps TDS to Payment Entry deductions and is idempotent through the stored entry name. - Migration M11 rebuilds erpnext_sync to allow a conflict status and adds attachment_sha256. - Commands for single and bulk push, payment push, sync status and the open-in-ERPNext URL, with typed wrappers. No UI yet. Tested against a mock server only. The Payment Entry deductions fields, the series-mode submit route and the payment amount semantics need a live ERPNext check.
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
//! Tauri commands for the ERPNext settings: read/save the configuration, test a connection and load the
|
||||
//! lists the settings dropdowns need. The API secret never leaves this module: `erpnext_get_config` returns
|
||||
//! `apiSecretSet` only, and every command resolves a blank secret to the stored one on the Rust side.
|
||||
//! Pushing invoices is a later step.
|
||||
//! The push commands send issued invoices and payments (see `integrations::erpnext::push`); the database lock is
|
||||
//! never held across a network call.
|
||||
|
||||
use crate::integrations::erpnext::client::ErpClient;
|
||||
use crate::integrations::erpnext::config::{self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView};
|
||||
use crate::integrations::erpnext::discovery::{self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts};
|
||||
use crate::integrations::erpnext::push::{self, PaymentPushResult, PushResult};
|
||||
use crate::integrations::SyncStatus;
|
||||
use crate::AppState;
|
||||
use rusqlite::Connection;
|
||||
use tauri::State;
|
||||
@@ -95,6 +98,67 @@ pub async fn erpnext_load_options(
|
||||
Ok(client.load_options(&cfg.company).await?)
|
||||
}
|
||||
|
||||
/// A client for the saved settings, or a readable reason why there is none.
|
||||
fn saved_client(state: &AppState) -> Result<ErpClient, String> {
|
||||
let cfg = {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
config::load(&conn)?
|
||||
};
|
||||
if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() {
|
||||
return Err("ERPNext is not set up yet: enter the address, API key and API secret in Settings.".into());
|
||||
}
|
||||
Ok(ErpClient::from_config(&cfg)?)
|
||||
}
|
||||
|
||||
/// Sends one issued invoice. `submit` omitted follows the "submit on push" setting. A failure comes back as a
|
||||
/// result with `ok: false` (and is recorded), not as an `Err`.
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_push_invoice(
|
||||
state: State<'_, AppState>,
|
||||
id: i64,
|
||||
submit: Option<bool>,
|
||||
) -> Result<PushResult, String> {
|
||||
let http = saved_client(&state)?;
|
||||
Ok(push::push_invoice(&state.db, &state.local_data_dir, &http, id, submit).await)
|
||||
}
|
||||
|
||||
/// Sends several invoices one after another; one row's failure never stops the others.
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_push_invoices(
|
||||
state: State<'_, AppState>,
|
||||
ids: Vec<i64>,
|
||||
submit: Option<bool>,
|
||||
) -> Result<Vec<PushResult>, String> {
|
||||
let http = saved_client(&state)?;
|
||||
Ok(push::push_invoices(&state.db, &state.local_data_dir, &http, &ids, submit).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn erpnext_push_payment(state: State<'_, AppState>, payment_id: i64) -> Result<PaymentPushResult, String> {
|
||||
let http = saved_client(&state)?;
|
||||
Ok(push::push_payment(&state.db, &http, payment_id).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn erpnext_sync_status(state: State<AppState>, id: i64) -> Result<SyncStatus, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
push::sync_status(&conn, id)
|
||||
}
|
||||
|
||||
/// The sync state of every invoice that has one, for the History list.
|
||||
#[tauri::command]
|
||||
pub fn erpnext_sync_statuses(state: State<AppState>) -> Result<Vec<SyncStatus>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
push::sync_statuses(&conn)
|
||||
}
|
||||
|
||||
/// `<base>/app/sales-invoice/<name>` for an invoice that was sent.
|
||||
#[tauri::command]
|
||||
pub fn erpnext_open_url(state: State<AppState>, id: i64) -> Result<String, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
push::open_url(&conn, id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+63
-2
@@ -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<String>, Option<String>) = 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 ('AP/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, 'AP/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<String>) = 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,
|
||||
("AP/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();
|
||||
|
||||
@@ -90,6 +90,20 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
|
||||
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<Value, ErpError> {
|
||||
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<Value, ErpError> {
|
||||
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<Value, ErpError> {
|
||||
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<Value, ErpError> {
|
||||
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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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: "Arun Co".into(),
|
||||
company_address: "Arun 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<SyncRow> {
|
||||
load_sync(&self.db.lock().unwrap(), id).unwrap()
|
||||
}
|
||||
|
||||
fn scalar(&self, sql: &str) -> Option<String> {
|
||||
self.db.lock().unwrap().query_row(sql, [], |r| r.get::<_, Option<String>>(0)).unwrap()
|
||||
}
|
||||
|
||||
async fn push(&self, mock: &Mock, id: i64, submit: Option<bool>) -> 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<String>, status: u16, body: Value) -> Route {
|
||||
(prefix.into(), status, body)
|
||||
}
|
||||
|
||||
async fn mock(routes: Vec<Route>) -> 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<Route> {
|
||||
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=\"AP-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": "<div>Row 1: <b>Rate</b> is required</div>" }).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<bool> = 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("AP/2026-001"), "AP-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: "AP/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", "AP/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: "AP/2026-001",
|
||||
remote_invoice: "AP/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("AP/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 AP/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);
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<bool>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// 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<Output = Result<PushedInvoice, String>> + Send + 'a;
|
||||
|
||||
fn push_payment<'a>(
|
||||
&'a self,
|
||||
request: PaymentRequest,
|
||||
) -> impl Future<Output = Result<PushedPayment, String>> + Send + 'a;
|
||||
|
||||
/// The locally recorded state; never touches the network.
|
||||
fn status(&self, invoice_id: i64) -> Result<SyncStatus, String>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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<ErpnextConnectionTest>("erpnext_test_connection", { config }),
|
||||
erpnextLoadOptions: (config: ErpnextConfigInput) => invoke<ErpnextOptions>("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<ErpnextPushResult>("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<ErpnextPushResult[]>("erpnext_push_invoices", { ids, submit: submit ?? null }),
|
||||
erpnextPushPayment: (paymentId: number) =>
|
||||
invoke<ErpnextPaymentPushResult>("erpnext_push_payment", { paymentId }),
|
||||
erpnextSyncStatus: (id: number) => invoke<ErpnextSyncStatus>("erpnext_sync_status", { id }),
|
||||
/** Every invoice that has a sync row (invoices never pushed are absent). */
|
||||
erpnextSyncStatuses: () => invoke<ErpnextSyncStatus[]>("erpnext_sync_statuses"),
|
||||
/** `<base>/app/sales-invoice/<name>`; rejects when the invoice was never sent. */
|
||||
erpnextOpenUrl: (id: number) => invoke<string>("erpnext_open_url", { id }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user