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

- ErpnextSink implements InvoiceSink. Pushing an issued invoice ensures the Customer and Address (read-back of the returned name, local PIN/state/GSTIN checks), creates the Sales Invoice, optionally submits it in a second call, and attaches the archived PDF as a private file once.
- Idempotent: a mirrored-name 409 is compared by grand total (same total records as synced, different total is a conflict and never overwrites); series mode looks up an existing document by its remarks before re-posting; an identical re-push sends nothing.
- Create and submit are separate calls so a draft survives a failed submit. A failed push is recorded in erpnext_sync with a readable message. Bulk push never aborts on one failing row.
- Payment push requires a submitted parent, maps TDS to Payment Entry deductions and is idempotent through the stored entry name.
- Migration M11 rebuilds erpnext_sync to allow a conflict status and adds attachment_sha256.
- Commands for single and bulk push, payment push, sync status and the open-in-ERPNext URL, with typed wrappers. No UI yet.

Tested against a mock server only. The Payment Entry deductions fields, the series-mode submit route and the payment amount semantics need a live ERPNext check.
This commit is contained in:
2026-10-04 20:33:45 +05:30
parent 86b4d436fa
commit d3b5fbb1b8
12 changed files with 2356 additions and 18 deletions
+38 -5
View File
@@ -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 {
+10 -1
View File
@@ -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))
}
+2 -1
View File
@@ -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: "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<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=\"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": "<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("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);
}
+58 -7
View File
@@ -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>;
}