|
|
|
@@ -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);
|
|
|
|
|
}
|