Verify the ERPNext integration live and fix the defects it found (Phase F4)
Ran the push flow against ERPNext 15.121.6 on a plain site and on a site with India Compliance 15.32.0 (podman, scripts/erpnext-e2e). 15 ignored live tests pass on both. Fixes, each covered by a mock-based test: - load_options no longer fails on a plain site: the India Compliance-only gstin field is dropped on a 417 and retried. - Code-less rows with fractional hours or minutes send stock_uom, since ERPNext defaults it to Nos. - A created document whose total differs from Voiced's (ERPNext's default Banker's Rounding on half-paise ties) is recorded as a conflict, kept as a draft, not submitted and not given a PDF. The message names Commercial Rounding as the fix. - An existing customer address is reused instead of creating a duplicate on every first push. A bare creation order-by is not used with a Dynamic Link filter. - A re-adopted document no longer gets a second copy of the PDF. - Reverse charge: India Compliance rejects is_reverse_charge unless tax rows are negative RCM amounts, so the flag is sent as 0 with a warning that the invoice lands as a normal taxed invoice. - The 16-character name rule is checked locally in mirror mode under India Compliance, and a TDS-only payment is refused locally with the reason. Payment Entry mapping verified: bank_account is the Account name (paid_to), paid and received amounts equal the cash received, allocated_amount is cash plus TDS, and TDS is one positive deductions row. The integration user needs the Accounts User and Sales User roles. Not verified: ERPNext v14 and v16, other India Compliance versions, the Tauri commands and UI against a live site, SEZ and overseas customers, UTGST supplier states, TLS sites, e-invoicing.
This commit is contained in:
@@ -286,18 +286,19 @@ impl ErpClient {
|
||||
return Ok(out);
|
||||
}
|
||||
load!("companyAddresses", out.company_addresses, async {
|
||||
self.list_resource(
|
||||
"Address",
|
||||
&["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"],
|
||||
json!([
|
||||
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||
["Dynamic Link", "link_name", "=", company],
|
||||
["disabled", "=", 0]
|
||||
]),
|
||||
"name asc",
|
||||
)
|
||||
.await
|
||||
.map(|rows| {
|
||||
let filters = json!([
|
||||
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||
["Dynamic Link", "link_name", "=", company],
|
||||
["disabled", "=", 0]
|
||||
]);
|
||||
let base = ["name", "address_title", "address_line1", "city", "state", "pincode"];
|
||||
let with_gstin = ["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"];
|
||||
// `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted").
|
||||
let rows = match self.list_resource("Address", &with_gstin, filters.clone(), "name asc").await {
|
||||
Err(e) if e.kind == ErrorKind::Validation => self.list_resource("Address", &base, filters, "name asc").await?,
|
||||
other => other?,
|
||||
};
|
||||
Ok::<_, ErpError>(
|
||||
rows.iter()
|
||||
.filter_map(|r| {
|
||||
item(
|
||||
@@ -306,8 +307,8 @@ impl ErpClient {
|
||||
join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
});
|
||||
load!("incomeAccounts", out.income_accounts, async {
|
||||
self.list_resource(
|
||||
@@ -792,6 +793,26 @@ mod tests {
|
||||
assert_eq!(json["features"]["v2Naming"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn company_addresses_fall_back_when_the_site_has_no_gstin_field() {
|
||||
// A plain ERPNext (no India Compliance) rejects the custom `gstin` field with a 417.
|
||||
let mock = serve_fn(|_, line| {
|
||||
let path = line.split_whitespace().nth(1).unwrap_or("");
|
||||
if path.starts_with("/api/resource/Address?") && path.contains("gstin") {
|
||||
reply(417, json!({ "exc_type": "ValidationError", "_error_message": "Field not permitted in query: gstin" }))
|
||||
} else if path.starts_with("/api/resource/Address?") {
|
||||
reply(200, json!({ "data": [{ "name": "Arun Co-Billing", "address_title": "Arun Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001" }] }))
|
||||
} else {
|
||||
reply(200, json!({ "data": [] }))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let options = client(&mock).load_options("Arun Co").await.unwrap();
|
||||
assert_eq!(options.company_addresses.len(), 1);
|
||||
assert_eq!(options.company_addresses[0].name, "Arun Co-Billing");
|
||||
assert!(options.errors.iter().all(|e| e.list != "companyAddresses"), "{:?}", options.errors);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
|
||||
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
//! Live tests against a real ERPNext site. All `#[ignore]`d: they need the stack from
|
||||
//! `scripts/erpnext-e2e/README.md` and these environment variables (the setup script prints them):
|
||||
//!
|
||||
//! - `ERPNEXT_URL`, `ERPNEXT_KEY`, `ERPNEXT_SECRET`: the integration user's credentials;
|
||||
//! - `ERPNEXT_COMPANY` (default `Voiced Test Co`) and `ERPNEXT_ABBR` (default `VTC`): the company and its
|
||||
//! abbreviation, from which the account names (`Sales - VTC`, `Output Tax CGST - VTC` ...) are derived;
|
||||
//! - `ERPNEXT_IC=1` when the site has India Compliance (enables the IC-only tests).
|
||||
//!
|
||||
//! Run: `cargo test --manifest-path src-tauri/Cargo.toml live_ -- --ignored --test-threads=1`.
|
||||
//! Every test makes its own invoice series (unique prefix per run), so repeated runs never collide.
|
||||
|
||||
use super::client::{ClientOptions, ErpClient};
|
||||
use super::config::{self, ErpnextConfig, NamingMode, Secret};
|
||||
use super::errors::ErrorKind;
|
||||
use super::discovery::{test_connection, ConnectionTest, LocalFacts};
|
||||
use super::push::{push_invoice, push_payment, PushResult};
|
||||
use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl};
|
||||
use crate::commands::payments::{record_payment_impl, PaymentInput};
|
||||
use crate::commands::series::start_new_series_impl;
|
||||
use crate::models::{Invoice, InvoiceInput};
|
||||
use rusqlite::params;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const VENDOR_GSTIN: &str = "27AAPFU0939F1ZV";
|
||||
const KA_GSTIN: &str = "29AABCU9603R1ZJ";
|
||||
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
fn env(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| panic!("set {name} (see scripts/erpnext-e2e/README.md)"))
|
||||
}
|
||||
|
||||
fn env_or(name: &str, default: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
|
||||
fn base36(mut n: u64, width: usize) -> String {
|
||||
const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
let mut out = vec![b'0'; width];
|
||||
for slot in out.iter_mut().rev() {
|
||||
*slot = DIGITS[(n % 36) as usize];
|
||||
n /= 36;
|
||||
}
|
||||
String::from_utf8(out).unwrap()
|
||||
}
|
||||
|
||||
struct Live {
|
||||
db: Mutex<rusqlite::Connection>,
|
||||
dir: tempfile::TempDir,
|
||||
http: ErpClient,
|
||||
abbr: String,
|
||||
ic: bool,
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
async fn new(edit: impl FnOnce(&mut ErpnextConfig)) -> Live {
|
||||
let abbr = env_or("ERPNEXT_ABBR", "VTC");
|
||||
let company = env_or("ERPNEXT_COMPANY", "Voiced Test Co");
|
||||
let ic = env_or("ERPNEXT_IC", "0") == "1";
|
||||
let mut cfg = ErpnextConfig {
|
||||
base_url: env("ERPNEXT_URL"),
|
||||
api_key: env("ERPNEXT_KEY"),
|
||||
api_secret: Secret::new(env("ERPNEXT_SECRET")),
|
||||
company_address: format!("{company}-Billing"),
|
||||
company,
|
||||
income_account: format!("Sales - {abbr}"),
|
||||
cgst_account: format!("Output Tax CGST - {abbr}"),
|
||||
sgst_account: format!("Output Tax SGST - {abbr}"),
|
||||
utgst_account: format!("Output Tax UTGST - {abbr}"),
|
||||
igst_account: format!("Output Tax IGST - {abbr}"),
|
||||
cost_center: format!("Main - {abbr}"),
|
||||
tds_account: format!("TDS Receivable - {abbr}"),
|
||||
payment_bank_account: env_or("ERPNEXT_BANK_ACCOUNT", &format!("HDFC - {abbr}")),
|
||||
..Default::default()
|
||||
};
|
||||
edit(&mut cfg);
|
||||
let http = ErpClient::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, "", ClientOptions::default()).unwrap();
|
||||
|
||||
let mut 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 [
|
||||
("Voiced Live Intra Co", "", "27", "5 FC Road", "Pune", "411004", "unregistered"),
|
||||
("Voiced Live Inter Co", KA_GSTIN, "29", "12 MG Road", "Bengaluru", "560001", "registered_regular"),
|
||||
] {
|
||||
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 nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() as u64;
|
||||
let prefix = format!("L{}{}/", base36(nanos / 1_000_000, 4), base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1));
|
||||
start_new_series_impl(&mut conn, &prefix, 3).unwrap();
|
||||
config::save(&conn, &cfg).unwrap();
|
||||
|
||||
let live = Live { db: Mutex::new(conn), dir: tempfile::tempdir().unwrap(), http, abbr, ic, prefix };
|
||||
// The same step the UI performs before the first push.
|
||||
let ct = live.connection_test(&cfg).await;
|
||||
let mut cfg = cfg;
|
||||
cfg.last_detect_result = serde_json::to_string(&ct).unwrap();
|
||||
config::save(&live.db.lock().unwrap(), &cfg).unwrap();
|
||||
live
|
||||
}
|
||||
|
||||
async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest {
|
||||
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
||||
test_connection(&self.http, cfg, &local).await.expect("connection test")
|
||||
}
|
||||
|
||||
fn cfg(&self) -> ErpnextConfig {
|
||||
config::load(&self.db.lock().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn save_cfg(&self, edit: impl FnOnce(&mut ErpnextConfig)) {
|
||||
let mut cfg = self.cfg();
|
||||
edit(&mut cfg);
|
||||
config::save(&self.db.lock().unwrap(), &cfg).unwrap();
|
||||
}
|
||||
|
||||
fn preset(&self, description: &str, code: &str) {
|
||||
self.db
|
||||
.lock()
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
|
||||
VALUES (?1, '', 'fixed', 'unit', 0, 0, 'now', ?2)",
|
||||
params![description, code],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Issues an invoice for one of the two live clients. `extra` overrides input fields.
|
||||
fn issue(&self, intra: bool, items: Value, extra: Value) -> Invoice {
|
||||
let (client_id, name, pos, tax_type, gstin) = if intra {
|
||||
(1, "Voiced Live Intra Co", "27", "cgst_sgst", "")
|
||||
} else {
|
||||
(2, "Voiced Live Inter Co", "29", "igst", KA_GSTIN)
|
||||
};
|
||||
let mut input = json!({
|
||||
"invoiceDate": "2026-06-15",
|
||||
"dueDate": "2026-07-15",
|
||||
"clientId": client_id,
|
||||
"clientName": name,
|
||||
"clientGstin": gstin,
|
||||
"placeOfSupplyStateCode": pos,
|
||||
"taxType": tax_type,
|
||||
"taxRate": 18.0,
|
||||
"items": items,
|
||||
});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
input[k] = v.clone();
|
||||
}
|
||||
let input: InvoiceInput = serde_json::from_value(input).unwrap();
|
||||
let mut conn = self.db.lock().unwrap();
|
||||
let inv = issue_invoice_impl(&mut conn, self.dir.path(), input, &json!({})).unwrap();
|
||||
get_invoice_impl(&conn, inv.id).unwrap()
|
||||
}
|
||||
|
||||
/// Puts a small PDF in the archive and records its hash, as exporting the invoice would.
|
||||
fn archive(&self, inv: &Invoice) {
|
||||
let bytes = minimal_pdf(&inv.number);
|
||||
let sha = format!("{:x}", Sha256::digest(&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();
|
||||
}
|
||||
|
||||
async fn push(&self, inv: &Invoice, submit: Option<bool>) -> PushResult {
|
||||
push_invoice(&self.db, self.dir.path(), &self.http, inv.id, submit).await
|
||||
}
|
||||
|
||||
async fn doc(&self, doctype: &str, name: &str) -> Value {
|
||||
let v = self.http.get(&["api", "resource", doctype, name], &[]).await.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}"));
|
||||
v["data"].clone()
|
||||
}
|
||||
|
||||
async fn invoices_named(&self, number: &str) -> Vec<Value> {
|
||||
self.http
|
||||
.list_resource("Sales Invoice", &["name", "docstatus", "grand_total", "remarks"], json!([["remarks", "like", format!("%{number}%")]]), "creation asc")
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn files_of(&self, doctype: &str, name: &str) -> Vec<Value> {
|
||||
self.http
|
||||
.list_resource(
|
||||
"File",
|
||||
&["name", "file_name", "is_private", "file_url"],
|
||||
json!([["attached_to_doctype", "=", doctype], ["attached_to_name", "=", name]]),
|
||||
"creation asc",
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn payment(&self, inv: &Invoice, cash: i64, tds: i64) -> i64 {
|
||||
let mut conn = self.db.lock().unwrap();
|
||||
record_payment_impl(
|
||||
&mut conn,
|
||||
PaymentInput {
|
||||
invoice_id: inv.id,
|
||||
paid_on: "2026-06-20".into(),
|
||||
amount_paise: cash,
|
||||
tds_paise: tds,
|
||||
mode: "bank_transfer".into(),
|
||||
reference: "UTR-LIVE-1".into(),
|
||||
notes: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
}
|
||||
|
||||
/// A tiny but structurally valid one-page PDF; Frappe parses uploaded PDFs, so a stub header is rejected.
|
||||
fn minimal_pdf(label: &str) -> Vec<u8> {
|
||||
let stream = format!("BT /F1 12 Tf 20 100 Td ({label}) Tj ET");
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{stream}\nendstream", stream.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
];
|
||||
let mut out = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).into_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes());
|
||||
for o in offsets {
|
||||
out.extend(format!("{o:010} 00000 n \n").into_bytes());
|
||||
}
|
||||
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn paise(v: &Value) -> i64 {
|
||||
(v.as_f64().unwrap_or_else(|| panic!("not a number: {v}")) * 100.0).round() as i64
|
||||
}
|
||||
|
||||
fn assert_pushed(r: &PushResult) {
|
||||
assert!(r.ok, "push failed: {:?} {:?}", r.error, r.warnings);
|
||||
assert_eq!(r.status, "synced");
|
||||
}
|
||||
|
||||
/// The invoice as ERPNext holds it: total equals Voiced's, tax rows are what Voiced computed.
|
||||
async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
|
||||
assert_eq!(paise(&doc["grand_total"]), crate::gst::rupees_to_paise(inv.total), "grand_total of {}", inv.number);
|
||||
assert_eq!(doc["rounded_total"].as_f64().unwrap_or(0.0), 0.0, "rounding is disabled");
|
||||
assert_eq!(doc["posting_date"], inv.invoice_date.as_str());
|
||||
let taxes = doc["taxes"].as_array().unwrap();
|
||||
let by_head = |needle: &str| -> Option<i64> {
|
||||
taxes
|
||||
.iter()
|
||||
.find(|t| t["account_head"].as_str().unwrap_or("").contains(needle))
|
||||
.map(|t| paise(&t["tax_amount"]))
|
||||
};
|
||||
assert_eq!(by_head("CGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.cgst_amount).max(0), "CGST of {}", inv.number);
|
||||
// IGST rows contain "IGST"; "CGST" and "SGST" rows do not.
|
||||
assert_eq!(by_head("SGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.sgst_amount), "SGST of {}", inv.number);
|
||||
assert_eq!(by_head("IGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.igst_amount), "IGST of {}", inv.number);
|
||||
let _ = live;
|
||||
}
|
||||
|
||||
// ---- connection and discovery ----
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack"]
|
||||
async fn live_connection_and_options() {
|
||||
let live = Live::new(|_| {}).await;
|
||||
let cfg = live.cfg();
|
||||
let ct = live.connection_test(&cfg).await;
|
||||
println!("{}", serde_json::to_string_pretty(&ct).unwrap());
|
||||
assert!(ct.user.contains('@'));
|
||||
assert_eq!(ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")), Some(true));
|
||||
assert!(ct.versions.erpnext.is_some());
|
||||
assert_eq!(ct.india_compliance, live.ic);
|
||||
assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming");
|
||||
|
||||
let o = live.http.load_options(&cfg.company).await.unwrap();
|
||||
println!("option errors: {:?}", o.errors);
|
||||
assert!(o.errors.is_empty(), "{:?}", o.errors);
|
||||
let has = |list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
|
||||
assert!(has(&o.companies, &cfg.company));
|
||||
assert!(has(&o.income_accounts, &cfg.income_account), "{:?}", o.income_accounts);
|
||||
assert!(o.tax_accounts.iter().any(|a| a.name.contains("CGST")), "{:?}", o.tax_accounts);
|
||||
assert!(has(&o.uoms, "Hour") && has(&o.uoms, "Nos"));
|
||||
assert!(!o.company_addresses.is_empty());
|
||||
assert!(!o.naming_series.is_empty());
|
||||
assert!(!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty());
|
||||
}
|
||||
|
||||
// ---- Sales Invoice mapping ----
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack"]
|
||||
async fn live_push_matrix() {
|
||||
let live = Live::new(|_| {}).await;
|
||||
live.preset("Preset service", "VOICED-SERVICE");
|
||||
let hsn = |i: Value| {
|
||||
let mut i = i;
|
||||
if live.ic {
|
||||
i["hsnSac"] = json!("998314");
|
||||
}
|
||||
i
|
||||
};
|
||||
let cases: Vec<(&str, Invoice)> = vec![
|
||||
(
|
||||
"cgst_sgst",
|
||||
live.issue(true, json!([hsn(json!({"description": "Design", "mode": "fixed", "amount": 7310.0}))]), json!({})),
|
||||
),
|
||||
(
|
||||
"igst, fractional hours",
|
||||
live.issue(
|
||||
false,
|
||||
json!([hsn(json!({"description": "Consulting", "mode": "rate", "rate": 1234.56, "unit": "hour", "quantity": 2.5}))]),
|
||||
json!({}),
|
||||
),
|
||||
),
|
||||
(
|
||||
"discount, repeated description",
|
||||
live.issue(
|
||||
true,
|
||||
json!([
|
||||
hsn(json!({"description": "Logo", "mode": "fixed", "amount": 3000.0})),
|
||||
hsn(json!({"description": "Logo", "mode": "fixed", "amount": 2000.5})),
|
||||
]),
|
||||
json!({"discount": 500.25}),
|
||||
),
|
||||
),
|
||||
(
|
||||
"item code via preset",
|
||||
live.issue(
|
||||
true,
|
||||
json!([
|
||||
hsn(json!({"description": "Preset service", "mode": "fixed", "amount": 1000.0})),
|
||||
hsn(json!({"description": "Code-less", "mode": "fixed", "amount": 250.0})),
|
||||
]),
|
||||
json!({"notes": "Thanks\nsecond line"}),
|
||||
),
|
||||
),
|
||||
];
|
||||
for (label, inv) in &cases {
|
||||
live.archive(inv);
|
||||
let r = live.push(inv, None).await;
|
||||
println!("{label}: {} -> {} (warnings {:?})", inv.number, r.remote_name, r.warnings);
|
||||
assert_pushed(&r);
|
||||
assert!(r.created && r.attached, "{label}: {r:?}");
|
||||
assert_eq!(r.remote_name, inv.number, "mirror naming keeps the Voiced number");
|
||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||
assert_eq!(doc["docstatus"], 0);
|
||||
assert_matches(&live, inv, &doc).await;
|
||||
let files = live.files_of("Sales Invoice", &r.remote_name).await;
|
||||
assert_eq!(files.len(), 1, "{files:?}");
|
||||
assert_eq!(files[0]["is_private"], 1, "the PDF attachment is private");
|
||||
}
|
||||
|
||||
// Customer and address shape, as ERPNext stored them.
|
||||
let inter = &cases[1].1;
|
||||
let doc = live.doc("Sales Invoice", &inter.number).await;
|
||||
let customer = live.doc("Customer", doc["customer"].as_str().unwrap()).await;
|
||||
assert_eq!(customer["customer_name"], "Voiced Live Inter Co");
|
||||
assert_eq!(customer["customer_type"], "Company");
|
||||
let address = live.doc("Address", doc["customer_address"].as_str().unwrap()).await;
|
||||
println!("address: {}", json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]}));
|
||||
assert_eq!(address["state"], "Karnataka");
|
||||
assert_eq!(address["country"], "India");
|
||||
assert_eq!(address["address_type"], "Billing");
|
||||
assert_eq!(address["links"][0]["link_name"], customer["name"]);
|
||||
if live.ic {
|
||||
assert_eq!(customer["gstin"], KA_GSTIN);
|
||||
assert_eq!(customer["gst_category"], "Registered Regular");
|
||||
assert_eq!(address["gstin"], KA_GSTIN);
|
||||
assert_eq!(doc["place_of_supply"], "29-Karnataka");
|
||||
}
|
||||
// A fresh local database (lost link) reuses the customer's address instead of creating another.
|
||||
let again = Live::new(|_| {}).await;
|
||||
let inv = again.issue(false, json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
|
||||
assert_pushed(&again.push(&inv, None).await);
|
||||
let d2 = again.doc("Sales Invoice", &inv.number).await;
|
||||
assert_eq!(d2["customer_address"], doc["customer_address"], "address reused");
|
||||
assert_eq!(d2["customer"], doc["customer"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack"]
|
||||
async fn live_half_paise_rounding() {
|
||||
let live = Live::new(|_| {}).await;
|
||||
let banker = env_or("ERPNEXT_ROUNDING", "banker") == "banker";
|
||||
// (taxable amount, discount): 9% of 10.50 is 0.945, 18% of 0.25 is 0.045 and so on. Voiced rounds half up.
|
||||
// Half-even and half-up agree when the digit before the tie is odd (3.50 -> 0.315 -> 0.32), so only the
|
||||
// first two are certain to differ under Banker's Rounding.
|
||||
let ties = [(10.5, 0.0, true), (7310.5, 0.0, true), (3.5, 0.0, false), (0.25, 0.0, false), (99.99, 0.15, false)];
|
||||
for (amount, discount, tie) in ties {
|
||||
let inv = live.issue(
|
||||
true,
|
||||
json!([{"description": format!("Rounding {amount}"), "mode": "fixed", "amount": amount, "hsnSac": "998314"}]),
|
||||
json!({"discount": discount}),
|
||||
);
|
||||
let r = live.push(&inv, Some(true)).await;
|
||||
println!("{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}", r.ok, r.error);
|
||||
if tie && banker {
|
||||
// ERPNext's Banker's Rounding disagrees by a paise: the draft is reported, kept, and never submitted.
|
||||
assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}");
|
||||
let msg = r.error.as_deref().unwrap();
|
||||
assert!(msg.contains("Commercial Rounding") && msg.contains("does not submit"), "{msg}");
|
||||
assert_eq!(r.remote_name, inv.number);
|
||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||
assert_eq!(doc["docstatus"], 0, "a mismatching document is never submitted");
|
||||
assert!(live.files_of("Sales Invoice", &r.remote_name).await.is_empty());
|
||||
// Pushing again finds the same draft and reports the same conflict instead of creating another.
|
||||
let again = live.push(&inv, Some(true)).await;
|
||||
assert!(!again.ok && again.status == "conflict", "{again:?}");
|
||||
} else {
|
||||
assert_pushed(&r);
|
||||
assert_eq!(r.remote_docstatus, 1);
|
||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||
assert_matches(&live, &inv, &doc).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack"]
|
||||
async fn live_repush_is_idempotent() {
|
||||
let live = Live::new(|_| {}).await;
|
||||
let inv = live.issue(true, json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]), json!({}));
|
||||
live.archive(&inv);
|
||||
let first = live.push(&inv, None).await;
|
||||
assert_pushed(&first);
|
||||
assert!(first.created);
|
||||
// Second push with the sync row present: nothing to do.
|
||||
let again = live.push(&inv, None).await;
|
||||
assert_pushed(&again);
|
||||
assert!(again.no_op && !again.created, "{again:?}");
|
||||
// Lose the local sync state: the POST gets a 409 and the document is adopted, not duplicated.
|
||||
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
|
||||
let adopted = live.push(&inv, None).await;
|
||||
assert_pushed(&adopted);
|
||||
assert!(adopted.attached, "{adopted:?}");
|
||||
assert!(!adopted.created, "{adopted:?}");
|
||||
assert_eq!(adopted.remote_name, first.remote_name);
|
||||
let all = live
|
||||
.http
|
||||
.list_resource("Sales Invoice", &["name"], json!([["name", "=", inv.number.clone()]]), "name asc")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
// Losing the sync row also loses the attachment hash; the adopted document must not get a second copy.
|
||||
let files = live.files_of("Sales Invoice", &first.remote_name).await;
|
||||
assert_eq!(files.len(), 1, "{files:?}");
|
||||
assert_eq!(files[0]["is_private"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack"]
|
||||
async fn live_submit_paths() {
|
||||
let live = Live::new(|_| {}).await;
|
||||
// Submit while creating.
|
||||
let a = live.issue(true, json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
|
||||
let r = live.push(&a, Some(true)).await;
|
||||
assert_pushed(&r);
|
||||
assert_eq!(r.remote_docstatus, 1);
|
||||
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
|
||||
// Submit an existing draft in a later push (mirror: v2 method route).
|
||||
let b = live.issue(true, json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]), json!({}));
|
||||
assert_pushed(&live.push(&b, Some(false)).await);
|
||||
let r = live.push(&b, Some(true)).await;
|
||||
assert_pushed(&r);
|
||||
assert_eq!(r.remote_docstatus, 1);
|
||||
assert_eq!(live.doc("Sales Invoice", &b.number).await["docstatus"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack"]
|
||||
async fn live_series_naming_mode() {
|
||||
let live = Live::new(|c| c.naming_mode = NamingMode::Series).await;
|
||||
let o = live.http.load_options(&live.cfg().company).await.unwrap();
|
||||
let series = o.naming_series.first().expect("a naming series").name.clone();
|
||||
println!("series options: {:?}", o.naming_series);
|
||||
live.save_cfg(|c| c.naming_series = series.clone());
|
||||
let inv = live.issue(true, json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]), json!({"notes": "note"}));
|
||||
live.archive(&inv);
|
||||
let r = live.push(&inv, Some(false)).await;
|
||||
assert_pushed(&r);
|
||||
assert!(r.created);
|
||||
assert_ne!(r.remote_name, inv.number, "series mode lets ERPNext name it");
|
||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||
assert!(doc["remarks"].as_str().unwrap().starts_with(&format!("Voiced invoice {}", inv.number)), "{}", doc["remarks"]);
|
||||
assert_matches(&live, &inv, &doc).await;
|
||||
// Lose the sync row: the remarks lookup finds the document instead of creating another.
|
||||
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
|
||||
let again = live.push(&inv, Some(false)).await;
|
||||
assert_pushed(&again);
|
||||
assert!(!again.created, "{again:?}");
|
||||
assert_eq!(again.remote_name, r.remote_name);
|
||||
assert_eq!(live.invoices_named(&inv.number).await.len(), 1);
|
||||
// Submit through the v1 run_method route.
|
||||
let s = live.push(&inv, Some(true)).await;
|
||||
assert_pushed(&s);
|
||||
assert_eq!(s.remote_docstatus, 1);
|
||||
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack"]
|
||||
async fn live_uom_and_quantities() {
|
||||
let live = Live::new(|_| {}).await;
|
||||
live.preset("Nos preset", "VOICED-SERVICE"); // stock UOM Nos
|
||||
live.preset("Hourly preset", "VOICED-HOURLY"); // stock UOM Hour
|
||||
let row = |desc: &str, unit: &str, qty: f64| json!([{"description": desc, "mode": "rate", "rate": 100.0, "unit": unit, "quantity": qty, "hsnSac": "998314"}]);
|
||||
let mut outcomes = Vec::new();
|
||||
for (label, desc, unit, qty) in [
|
||||
("Hour row on a Nos item, whole qty", "Nos preset", "hour", 2.0),
|
||||
("Hour row on a Nos item, fractional qty", "Nos preset", "hour", 1.5),
|
||||
("Hour row on an Hour item, fractional qty", "Hourly preset", "hour", 1.5),
|
||||
("Nos row on an Hour item, whole qty", "Hourly preset", "unit", 3.0),
|
||||
("code-less hours, fractional qty", "Code-less hours", "hour", 1.5),
|
||||
("code-less minutes, fractional qty", "Code-less minutes", "minute", 90.5),
|
||||
("code-less Nos, fractional qty", "Code-less nos", "unit", 1.5),
|
||||
("code-less Nos, whole qty", "Code-less nos whole", "unit", 4.0),
|
||||
] {
|
||||
let inv = live.issue(true, row(desc, unit, qty), json!({}));
|
||||
let r = live.push(&inv, None).await;
|
||||
println!("{label}: ok={} {}", r.ok, r.error.clone().unwrap_or_default());
|
||||
outcomes.push((label, r.ok));
|
||||
}
|
||||
println!("{outcomes:?}");
|
||||
let ok = |l: &str| outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
|
||||
assert!(ok("Hour row on an Hour item, fractional qty"));
|
||||
assert!(ok("code-less hours, fractional qty") && ok("code-less minutes, fractional qty"));
|
||||
assert!(ok("code-less Nos, whole qty"));
|
||||
assert!(!ok("code-less Nos, fractional qty"), "ERPNext refuses a fraction of a whole-number UOM");
|
||||
}
|
||||
|
||||
// ---- payments ----
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack"]
|
||||
async fn live_payment_entry() {
|
||||
let live = Live::new(|_| {}).await;
|
||||
let inv = live.issue(true, json!([{"description": "Billable", "mode": "fixed", "amount": 10000.0, "hsnSac": "998314"}]), json!({}));
|
||||
assert_pushed(&live.push(&inv, Some(true)).await);
|
||||
let total = crate::gst::rupees_to_paise(inv.total);
|
||||
// 10% TDS on the taxable value: 1000.00; the cash received is the rest.
|
||||
let tds = 100_000;
|
||||
let cash = total - tds;
|
||||
let pid = live.payment(&inv, cash, tds);
|
||||
let r = push_payment(&live.db, &live.http, pid).await;
|
||||
println!("payment: {r:?}");
|
||||
assert!(r.ok, "{:?}", r.error);
|
||||
let entry = r.entry_name.clone().unwrap();
|
||||
let pe = live.doc("Payment Entry", &entry).await;
|
||||
let keys = ["payment_type", "party_type", "paid_from", "paid_to", "paid_amount", "received_amount", "base_paid_amount", "base_received_amount", "total_allocated_amount", "unallocated_amount", "difference_amount", "reference_no", "reference_date", "posting_date", "bank_account", "mode_of_payment", "status"];
|
||||
println!("PE {}", json!(keys.iter().map(|k| (k.to_string(), pe[*k].clone())).collect::<serde_json::Map<_, _>>()));
|
||||
println!("PE references {}", pe["references"].as_array().unwrap().iter().map(|r| json!({"ref": r["reference_name"], "allocated": r["allocated_amount"], "total": r["total_amount"], "outstanding": r["outstanding_amount"]}).to_string()).collect::<Vec<_>>().join(","));
|
||||
println!("PE deductions {}", pe["deductions"].as_array().unwrap().iter().map(|r| json!({"account": r["account"], "cost_center": r["cost_center"], "amount": r["amount"], "description": r["description"]}).to_string()).collect::<Vec<_>>().join(","));
|
||||
assert_eq!(paise(&pe["paid_amount"]), cash);
|
||||
assert_eq!(paise(&pe["received_amount"]), cash);
|
||||
assert_eq!(paise(&pe["total_allocated_amount"]), cash + tds);
|
||||
assert_eq!(paise(&pe["difference_amount"]), 0);
|
||||
let ded = pe["deductions"].as_array().unwrap();
|
||||
assert_eq!(ded.len(), 1);
|
||||
assert_eq!(ded[0]["account"], live.cfg().tds_account.as_str());
|
||||
assert_eq!(paise(&ded[0]["amount"]), tds, "a TDS deduction row is a positive amount");
|
||||
assert_eq!(pe["reference_no"], "UTR-LIVE-1");
|
||||
assert_eq!(pe["posting_date"], "2026-06-20");
|
||||
assert_eq!(pe["paid_to"], live.cfg().payment_bank_account.as_str(), "money lands in the configured bank account");
|
||||
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
|
||||
println!("outstanding after payment: {}", inv_doc["outstanding_amount"]);
|
||||
assert_eq!(pe["docstatus"], 1);
|
||||
assert_eq!(paise(&inv_doc["outstanding_amount"]), 0, "cash plus TDS settles the invoice");
|
||||
// Idempotent locally.
|
||||
let again = push_payment(&live.db, &live.http, pid).await;
|
||||
assert!(again.ok && again.already_synced);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack"]
|
||||
async fn live_payment_without_tds() {
|
||||
let live = Live::new(|_| {}).await;
|
||||
let inv = live.issue(true, json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
||||
assert_pushed(&live.push(&inv, Some(true)).await);
|
||||
let total = crate::gst::rupees_to_paise(inv.total);
|
||||
let pid = live.payment(&inv, total / 2, 0);
|
||||
let r = push_payment(&live.db, &live.http, pid).await;
|
||||
assert!(r.ok, "{:?}", r.error);
|
||||
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
|
||||
assert_eq!(paise(&inv_doc["outstanding_amount"]), total - total / 2, "partial payment leaves the rest outstanding");
|
||||
}
|
||||
|
||||
// ---- India Compliance only (ERPNEXT_IC=1) ----
|
||||
|
||||
fn skip_without_ic() -> bool {
|
||||
if env_or("ERPNEXT_IC", "0") != "1" {
|
||||
eprintln!("skipped: the site has no India Compliance (ERPNEXT_IC is not 1)");
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack with India Compliance"]
|
||||
async fn live_ic_hsn_enforcement() {
|
||||
if skip_without_ic() {
|
||||
return;
|
||||
}
|
||||
let live = Live::new(|_| {}).await;
|
||||
let mut outcomes = Vec::new();
|
||||
for (label, hsn) in [("no HSN", ""), ("4-digit HSN", "9983"), ("6-digit HSN", "998314"), ("8-digit HSN", "99831400")] {
|
||||
let inv = live.issue(true, json!([{"description": format!("HSN {label}"), "mode": "fixed", "amount": 1000.0, "hsnSac": hsn}]), json!({}));
|
||||
let r = live.push(&inv, Some(true)).await;
|
||||
println!("{label}: ok={} docstatus={} err={:?}", r.ok, r.remote_docstatus, r.error);
|
||||
outcomes.push((label, r));
|
||||
}
|
||||
let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
|
||||
assert!(get("6-digit HSN").ok && get("6-digit HSN").remote_docstatus == 1);
|
||||
assert!(get("8-digit HSN").ok && get("8-digit HSN").remote_docstatus == 1);
|
||||
for bad in ["no HSN", "4-digit HSN"] {
|
||||
let r = get(bad);
|
||||
assert!(!r.ok, "{bad} must be refused: {r:?}");
|
||||
let msg = r.error.as_deref().unwrap();
|
||||
assert!(msg.to_ascii_uppercase().contains("HSN"), "{bad}: {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack with India Compliance"]
|
||||
async fn live_ic_number_longer_than_16_characters() {
|
||||
if skip_without_ic() {
|
||||
return;
|
||||
}
|
||||
let live = Live::new(|_| {}).await;
|
||||
let inv = live.issue(true, json!([{"description": "Long number", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
||||
// Voiced never issues such a number; this is a number imported or migrated from elsewhere.
|
||||
let long = format!("{}-EXTRA-LONG-PART", inv.number);
|
||||
assert!(long.len() > 16);
|
||||
live.db.lock().unwrap().execute("UPDATE invoices SET number = ?1 WHERE id = ?2", params![long, inv.id]).unwrap();
|
||||
let r = live.push(&inv, None).await;
|
||||
println!("long number: ok={} err={:?}", r.ok, r.error);
|
||||
assert!(!r.ok);
|
||||
let msg = r.error.as_deref().unwrap();
|
||||
assert!(msg.contains("16"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack with India Compliance"]
|
||||
async fn live_ic_gst_account_warnings() {
|
||||
if skip_without_ic() {
|
||||
return;
|
||||
}
|
||||
let live = Live::new(|_| {}).await;
|
||||
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
||||
let codes = |ct: &ConnectionTest| ct.warnings.iter().map(|w| w.code.clone()).collect::<Vec<_>>();
|
||||
|
||||
let clean = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
|
||||
assert!(clean.warnings.is_empty(), "{:?}", clean.warnings);
|
||||
|
||||
let mut cfg = live.cfg();
|
||||
cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr);
|
||||
let wrong = test_connection(&live.http, &cfg, &local).await.unwrap();
|
||||
assert_eq!(codes(&wrong), ["gst_account_mismatch"], "{:?}", wrong.warnings);
|
||||
|
||||
let mut cfg = live.cfg();
|
||||
cfg.company = "No Such Co".into();
|
||||
let other = test_connection(&live.http, &cfg, &local).await.unwrap();
|
||||
assert!(codes(&other).contains(&"gst_accounts_not_configured".to_string()), "{:?}", other.warnings);
|
||||
|
||||
// Mirror mode with a number the site would refuse.
|
||||
let local = LocalFacts { vendor_registered: true, next_number: Some("TOOLONG-INVOICE-0001".into()), ..Default::default() };
|
||||
let ct = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
|
||||
assert!(codes(&ct).contains(&"number_invalid".to_string()), "{:?}", ct.warnings);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack with India Compliance"]
|
||||
async fn live_ic_reverse_charge_and_gst_fields() {
|
||||
if skip_without_ic() {
|
||||
return;
|
||||
}
|
||||
let live = Live::new(|_| {}).await;
|
||||
let inv = live.issue(false, json!([{"description": "RCM service", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({"reverseCharge": true}));
|
||||
let r = live.push(&inv, None).await;
|
||||
// India Compliance refuses is_reverse_charge=1 without negative RCM tax rows ("Booked reverse charge is not
|
||||
// equal to applied tax amount"), whatever GST Settings say, so it is sent as 0 with a warning.
|
||||
assert_pushed(&r);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings);
|
||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||
assert_eq!(doc["is_reverse_charge"], 0);
|
||||
assert_matches(&live, &inv, &doc).await;
|
||||
assert_eq!(doc["place_of_supply"], "29-Karnataka");
|
||||
assert_eq!(doc["company_gstin"], VENDOR_GSTIN);
|
||||
assert_eq!(doc["gst_category"], "Registered Regular");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack with India Compliance"]
|
||||
async fn live_ic_state_names_match_voiced() {
|
||||
if skip_without_ic() {
|
||||
return;
|
||||
}
|
||||
let live = Live::new(|_| {}).await;
|
||||
let v = live.http.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Address".to_string())]).await.unwrap();
|
||||
let docs = v["docs"].as_array().unwrap();
|
||||
let address = docs.iter().find(|d| d["name"] == "Address").unwrap();
|
||||
let options = address["fields"].as_array().unwrap().iter().find(|f| f["fieldname"] == "gst_state").expect("IC adds gst_state");
|
||||
let ic: Vec<&str> = options["options"].as_str().unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).collect();
|
||||
for (code, name) in crate::gst::STATES {
|
||||
assert!(ic.contains(name), "India Compliance has no state named {name:?} ({code}); it has {ic:?}");
|
||||
}
|
||||
assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs the live ERPNext stack"]
|
||||
async fn live_payment_edge_cases() {
|
||||
let live = Live::new(|_| {}).await;
|
||||
let inv = live.issue(true, json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
||||
// Not submitted yet: refused locally, nothing sent.
|
||||
assert_pushed(&live.push(&inv, Some(false)).await);
|
||||
let early = live.payment(&inv, 100_00, 0);
|
||||
let r = push_payment(&live.db, &live.http, early).await;
|
||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("not submitted"), "{r:?}");
|
||||
assert_pushed(&live.push(&inv, Some(true)).await);
|
||||
let total = crate::gst::rupees_to_paise(inv.total);
|
||||
// TDS only (no cash): settled by deduction alone.
|
||||
let tds_only = live.payment(&inv, 0, total - 100_00);
|
||||
let r = push_payment(&live.db, &live.http, tds_only).await;
|
||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS only"), "{r:?}");
|
||||
// Without a configured TDS account a TDS payment is refused locally.
|
||||
live.save_cfg(|c| c.tds_account = String::new());
|
||||
let inv2 = live.issue(true, json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
||||
assert_pushed(&live.push(&inv2, Some(true)).await);
|
||||
let t = live.payment(&inv2, 100_00, 50_00);
|
||||
let r = push_payment(&live.db, &live.http, t).await;
|
||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS account"), "{r:?}");
|
||||
}
|
||||
@@ -258,6 +258,7 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
|
||||
.filter(|c| !c.is_empty())
|
||||
.or_else(|| Some(cfg.default_item_code.trim()).filter(|c| !c.is_empty()));
|
||||
|
||||
let code_less = code.is_none();
|
||||
let mut row = Map::new();
|
||||
match code {
|
||||
Some(code) => {
|
||||
@@ -276,8 +277,13 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
|
||||
row.insert("description".into(), json!(descriptions[i]));
|
||||
row.insert("qty".into(), decimal_number(&scaled_decimal(qty_scaled, 3)));
|
||||
row.insert("rate".into(), money(rate_paise));
|
||||
row.insert("uom".into(), json!(cfg.uom_for(&item.unit)));
|
||||
let uom = cfg.uom_for(&item.unit);
|
||||
row.insert("uom".into(), json!(uom));
|
||||
row.insert("conversion_factor".into(), json!(1));
|
||||
if code_less {
|
||||
// Without an item, ERPNext falls back to the stock UOM "Nos" and then refuses a fractional stock qty.
|
||||
row.insert("stock_uom".into(), json!(uom));
|
||||
}
|
||||
set_if(&mut row, "income_account", &cfg.income_account);
|
||||
set_if(&mut row, "cost_center", &cfg.cost_center);
|
||||
if apply_gst {
|
||||
@@ -379,7 +385,10 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
|
||||
inv.place_of_supply_state_code.trim()
|
||||
};
|
||||
body.insert("place_of_supply".into(), json!(place_of_supply_label(pos)?));
|
||||
body.insert("is_reverse_charge".into(), json!(i32::from(inv.reverse_charge)));
|
||||
// Live finding (India Compliance 15.32): a reverse-charge sales invoice must carry negative tax rows on the
|
||||
// separate "... RCM" accounts, so ERPNext's total would exclude the tax Voiced's total includes. The flag
|
||||
// is therefore never sent as 1; the push reports a warning for such invoices instead.
|
||||
body.insert("is_reverse_charge".into(), json!(0));
|
||||
}
|
||||
|
||||
if totals.discount > 0 {
|
||||
@@ -631,6 +640,7 @@ mod tests {
|
||||
"qty": 1.0,
|
||||
"rate": 7310.0,
|
||||
"uom": "Nos",
|
||||
"stock_uom": "Nos",
|
||||
"conversion_factor": 1,
|
||||
"income_account": "Sales - AC",
|
||||
}],
|
||||
@@ -680,17 +690,18 @@ mod tests {
|
||||
"company_address": "Arun Co-Billing",
|
||||
"customer_address": "Client Ltd-Billing",
|
||||
"place_of_supply": "29-Karnataka",
|
||||
"is_reverse_charge": 1,
|
||||
// Reverse charge is never sent as 1 (see build_sales_invoice); the push warns instead.
|
||||
"is_reverse_charge": 0,
|
||||
"apply_discount_on": "Net Total",
|
||||
"discount_amount": 10.0,
|
||||
"items": [
|
||||
{
|
||||
"item_name": "Hours", "description": "Hours", "qty": 1.5, "rate": 1200.0, "uom": "Hour",
|
||||
"conversion_factor": 1, "income_account": "Sales - AC", "gst_hsn_code": "998314",
|
||||
"stock_uom": "Hour", "conversion_factor": 1, "income_account": "Sales - AC", "gst_hsn_code": "998314",
|
||||
},
|
||||
{
|
||||
"item_name": "Fee", "description": "Fee", "qty": 1.0, "rate": 33.33, "uom": "Nos",
|
||||
"conversion_factor": 1, "income_account": "Sales - AC",
|
||||
"stock_uom": "Nos", "conversion_factor": 1, "income_account": "Sales - AC",
|
||||
},
|
||||
],
|
||||
"taxes": [
|
||||
@@ -768,6 +779,26 @@ mod tests {
|
||||
assert_eq!(rows[3]["item_name"], "Other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_code_less_rows_pin_the_stock_uom() {
|
||||
// Live finding: without an item ERPNext defaults the stock UOM to "Nos" and then refuses a fractional
|
||||
// quantity of hours; an item-coded row keeps the item's own stock UOM.
|
||||
let mut conn = registered();
|
||||
let inv = issue(
|
||||
&mut conn,
|
||||
json!({ "items": [
|
||||
{ "description": "Coded", "mode": "rate", "rate": 100.0, "unit": "hour", "quantity": 1.5 },
|
||||
{ "description": "Not coded", "mode": "rate", "rate": 100.0, "unit": "minute", "quantity": 2.5 },
|
||||
]}),
|
||||
);
|
||||
let codes = [Some("SVC".to_string()), None];
|
||||
let req = build(&inv, &cfg(), false, &codes, false).unwrap();
|
||||
let rows = req.body["items"].as_array().unwrap();
|
||||
assert!(rows[0].get("stock_uom").is_none());
|
||||
assert_eq!((rows[0]["uom"].as_str(), rows[0]["conversion_factor"].as_i64()), (Some("Hour"), Some(1)));
|
||||
assert_eq!((rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), (Some("Minute"), Some("Minute")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_less_rows_need_an_income_account() {
|
||||
let mut conn = registered();
|
||||
|
||||
@@ -13,3 +13,6 @@ pub mod push;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testutil;
|
||||
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use super::client::{ErpClient, Upload};
|
||||
use super::config::{self, ErpnextConfig, NamingMode};
|
||||
use super::discovery::ic_number_ok;
|
||||
use super::errors::{ErpError, ErrorKind};
|
||||
use super::mapping::{
|
||||
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, InvoiceContext, Vendor,
|
||||
@@ -561,8 +562,15 @@ async fn ensure_address(
|
||||
}
|
||||
validate_address(client).map_err(pre)?;
|
||||
let req = build_address(client, customer, l.india_compliance).map_err(pre)?;
|
||||
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
||||
let name = doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?;
|
||||
// The link to the address is lost when the local database is (re)built: reuse the customer's matching
|
||||
// address rather than creating a duplicate on every first push.
|
||||
let name = match find_address(http, customer, &req.body).await {
|
||||
Some(existing) => existing,
|
||||
None => {
|
||||
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
||||
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?
|
||||
}
|
||||
};
|
||||
with_db(db, |c| {
|
||||
c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id])
|
||||
.map(|_| ())
|
||||
@@ -571,10 +579,52 @@ async fn ensure_address(
|
||||
Ok((Some(name), None))
|
||||
}
|
||||
|
||||
/// An enabled address linked to `customer` with the same first line and PIN code as the one about to be created.
|
||||
/// Any lookup problem counts as "none": the create that follows reports the real error. (A bare `creation` in
|
||||
/// the order-by is ambiguous once a Dynamic Link filter joins the child table: HTTP 500.)
|
||||
async fn find_address(http: &ErpClient, customer: &str, body: &Value) -> Option<String> {
|
||||
let line1 = body.get("address_line1")?.as_str()?;
|
||||
let mut filters = vec![
|
||||
json!(["Dynamic Link", "link_doctype", "=", "Customer"]),
|
||||
json!(["Dynamic Link", "link_name", "=", customer]),
|
||||
json!(["address_line1", "=", line1]),
|
||||
json!(["disabled", "=", 0]),
|
||||
];
|
||||
if let Some(pin) = body.get("pincode").and_then(Value::as_str) {
|
||||
filters.push(json!(["pincode", "=", pin]));
|
||||
}
|
||||
let rows = http.list_resource("Address", &["name"], Value::Array(filters), "`tabAddress`.creation asc").await.ok()?;
|
||||
rows.first()?.get("name")?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
struct RemoteDoc {
|
||||
name: String,
|
||||
docstatus: i64,
|
||||
created: bool,
|
||||
/// The grand total ERPNext computed, when the response carried it.
|
||||
total_paise: Option<i64>,
|
||||
}
|
||||
|
||||
/// ERPNext's default Rounding Method is Banker's Rounding (half to even); Voiced rounds half up. They only
|
||||
/// differ on half-paise ties (e.g. 9% of 10.50), by one paise per tie.
|
||||
fn rounding_hint(theirs: i64, ours: i64) -> &'static str {
|
||||
if (theirs - ours).abs() <= 5 {
|
||||
" The usual cause is ERPNext's rounding of half-paise amounts: set System Settings > Rounding Method to \"Commercial Rounding\" in ERPNext."
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fn total_conflict(name: &str, number: &str, theirs: i64, ours: i64) -> ErpError {
|
||||
ErpError::new(
|
||||
ErrorKind::Conflict,
|
||||
format!(
|
||||
"ERPNext has {name} for invoice {number}, but its total is {} and Voiced's is {}. Voiced does not overwrite it and does not submit it: fix or delete the ERPNext document, then push again.{}",
|
||||
paise_to_decimal(theirs),
|
||||
paise_to_decimal(ours),
|
||||
rounding_hint(theirs, ours)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// An existing remote document is only accepted when its total equals Voiced's; anything else is a
|
||||
@@ -589,16 +639,10 @@ fn accept_existing(name: &str, doc: &Value, inv: &Invoice) -> Result<RemoteDoc,
|
||||
}
|
||||
let ours = gst::rupees_to_paise(inv.total);
|
||||
match doc_total_paise(doc) {
|
||||
Some(theirs) if theirs == ours => Ok(RemoteDoc { name: name.to_string(), docstatus, created: false }),
|
||||
Some(theirs) => Err(ErpError::new(
|
||||
ErrorKind::Conflict,
|
||||
format!(
|
||||
"ERPNext already has {name} for invoice {}, but its total is {} and Voiced's is {}. Voiced does not overwrite it: fix or delete the ERPNext document, then push again.",
|
||||
inv.number,
|
||||
paise_to_decimal(theirs),
|
||||
paise_to_decimal(ours)
|
||||
),
|
||||
)),
|
||||
Some(theirs) if theirs == ours => {
|
||||
Ok(RemoteDoc { name: name.to_string(), docstatus, created: false, total_paise: Some(theirs) })
|
||||
}
|
||||
Some(theirs) => Err(total_conflict(name, &inv.number, theirs, ours)),
|
||||
None => Err(ErpError::new(
|
||||
ErrorKind::Conflict,
|
||||
format!("ERPNext already has {name} for invoice {}, but its total could not be read to compare.", inv.number),
|
||||
@@ -647,7 +691,7 @@ async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltReque
|
||||
return Err(ErpError::protocol("ERPNext did not return the new Sales Invoice's name."))
|
||||
}
|
||||
};
|
||||
Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true })
|
||||
Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true, total_paise: doc_total_paise(&doc) })
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => {
|
||||
// The mirrored name is taken: either a repeat of an earlier push or someone else's document.
|
||||
@@ -696,8 +740,30 @@ fn attachment_file_name(number: &str) -> String {
|
||||
format!("{}.pdf", if cleaned.is_empty() { "invoice" } else { cleaned })
|
||||
}
|
||||
|
||||
async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf) -> Result<(), ErpError> {
|
||||
/// True when the document already carries a file of this name. Used when the local row lost its attachment
|
||||
/// hash (a re-adopted document), so the same PDF is not attached twice.
|
||||
async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) -> Result<bool, ErpError> {
|
||||
let rows = http
|
||||
.list_resource(
|
||||
"File",
|
||||
&["name"],
|
||||
json!([
|
||||
["attached_to_doctype", "=", DOCTYPE_INVOICE],
|
||||
["attached_to_name", "=", remote_name],
|
||||
["file_name", "=", file_name]
|
||||
]),
|
||||
"creation asc",
|
||||
)
|
||||
.await?;
|
||||
Ok(!rows.is_empty())
|
||||
}
|
||||
|
||||
/// `adopted`: the document was found, not created by this push, and nothing is recorded as attached.
|
||||
async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf, adopted: bool) -> Result<(), ErpError> {
|
||||
let file_name = attachment_file_name(&l.invoice.number);
|
||||
if adopted && has_attachment(http, remote_name, &file_name).await.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
let fields = [
|
||||
("doctype", DOCTYPE_INVOICE.to_string()),
|
||||
("docname", remote_name.to_string()),
|
||||
@@ -754,6 +820,9 @@ fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, stat
|
||||
fn failure_text(step: &str, e: &ErpError) -> String {
|
||||
if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) {
|
||||
e.to_string()
|
||||
} else if e.kind == ErrorKind::Validation && e.message.contains("cannot be a fraction") {
|
||||
// ERPNext names the row and the UOM already; say what to change in Voiced's settings.
|
||||
format!("Could not {step}: {e} Map this unit to a UOM that allows fractions (ERPNext settings, unit mapping), or use a whole quantity.")
|
||||
} else {
|
||||
format!("Could not {step}: {e}")
|
||||
}
|
||||
@@ -780,6 +849,12 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
||||
st.warnings.extend(address_warning);
|
||||
|
||||
st.step = "";
|
||||
if inv.reverse_charge && l.india_compliance && l.vendor.registered {
|
||||
st.warnings.push(
|
||||
"This invoice is marked reverse charge in Voiced. It was sent as a normal taxed invoice: India Compliance books reverse-charge sales on separate RCM tax accounts with negative tax rows, which would not match Voiced's total. Check its GST treatment in ERPNext."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let ctx = InvoiceContext {
|
||||
invoice: inv,
|
||||
config: cfg,
|
||||
@@ -807,11 +882,25 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
||||
}
|
||||
None => {
|
||||
st.payload_hash = hash;
|
||||
if cfg.naming_mode == NamingMode::Mirror && l.india_compliance && !ic_number_ok(&inv.number) {
|
||||
return Err(pre(format!(
|
||||
"Invoice number {} is longer than 16 characters or has characters India Compliance refuses (letters, digits, - and / only), so ERPNext would reject it. Use the ERPNext series naming mode, or start a new Voiced series with a shorter prefix.",
|
||||
inv.number
|
||||
)));
|
||||
}
|
||||
st.step = "create the Sales Invoice";
|
||||
let doc = create_or_find(http, l, &built).await?;
|
||||
st.remote_name = doc.name;
|
||||
st.remote_name = doc.name.clone();
|
||||
st.remote_docstatus = doc.docstatus;
|
||||
st.created = doc.created;
|
||||
// The totals must agree before anything else happens (no PDF, never submitted); the row keeps the
|
||||
// remote name, so the draft is found again after it is fixed or deleted.
|
||||
if let Some(theirs) = doc.total_paise {
|
||||
let ours = gst::rupees_to_paise(inv.total);
|
||||
if theirs != ours {
|
||||
return Err(total_conflict(&doc.name, &inv.number, theirs, ours));
|
||||
}
|
||||
}
|
||||
// Keep the remote name even if the next steps fail.
|
||||
persist(db, inv.id, prev, st, "synced", "")?;
|
||||
}
|
||||
@@ -834,7 +923,8 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
||||
}
|
||||
if let Some(pdf) = pdf {
|
||||
st.step = "attach the PDF";
|
||||
match attach_pdf(http, l, &st.remote_name.clone(), pdf).await {
|
||||
let adopted = !st.created && st.attachment_sha256.is_empty();
|
||||
match attach_pdf(http, l, &st.remote_name.clone(), pdf, adopted).await {
|
||||
Ok(()) => st.attachment_sha256 = pdf.sha256.clone(),
|
||||
// The invoice itself is in ERPNext; a failed upload is a warning, retried by the next push.
|
||||
Err(e) => st.warnings.push(format!("The PDF was not attached: {e}")),
|
||||
@@ -924,9 +1014,13 @@ pub struct PaymentEntryInput<'a> {
|
||||
|
||||
/// Turns the unsaved dict from `get_payment_entry` into the Payment Entry to insert and submit.
|
||||
///
|
||||
/// UNVERIFIED against a live ERPNext (check in F4): the deduction row fields (`account`, `cost_center`,
|
||||
/// `amount`), the sign ERPNext expects for a TDS deduction on a receipt, and whether `allocated_amount` must be
|
||||
/// cash plus TDS for the difference amount to come out zero. Everything that depends on those guesses is here.
|
||||
/// Verified against a live ERPNext v15.121.6 (F4; see scripts/erpnext-e2e): `bank_account` in the
|
||||
/// `get_payment_entry` query is the *Account* name and ends up as `paid_to` (the Payment Entry's own
|
||||
/// `bank_account` link, a Bank Account document, stays empty). A Payment Entry that settles an invoice with TDS has
|
||||
/// `paid_amount = received_amount = cash`, the invoice reference's `allocated_amount = cash + TDS`, and one
|
||||
/// `deductions` row `{account: TDS receivable, cost_center, amount: +TDS}` (a positive amount); then
|
||||
/// `difference_amount` is 0, `total_allocated_amount = cash + TDS` and the invoice's outstanding drops by
|
||||
/// cash + TDS. A partial payment without TDS leaves the rest outstanding.
|
||||
pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result<Value, String> {
|
||||
let mut doc: Map<String, Value> = draft.as_object().cloned().ok_or("ERPNext returned an unexpected payment draft.")?;
|
||||
doc.retain(|k, _| !k.starts_with("__"));
|
||||
@@ -1043,6 +1137,12 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
|
||||
)))
|
||||
}
|
||||
};
|
||||
if row.amount_paise <= 0 {
|
||||
// Live finding: ERPNext answers a zero paid amount with a bare "Paid Amount is mandatory".
|
||||
return fail(pre(
|
||||
"This payment records TDS only, with no cash received. ERPNext's Payment Entry needs a paid amount above zero, so it was not sent: book the TDS in ERPNext (for example as a Journal Entry), or send the payment once the cash is recorded.",
|
||||
));
|
||||
}
|
||||
if cfg.payment_bank_account.trim().is_empty() {
|
||||
return fail(pre("Set the payment bank account in the ERPNext settings first."));
|
||||
}
|
||||
|
||||
@@ -274,6 +274,139 @@ async fn submit_on_push_setting_is_the_default_and_a_failed_submit_keeps_the_dra
|
||||
assert!(row.last_error.contains("HSN code is mandatory"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_created_invoice_whose_total_differs_is_a_conflict_that_is_kept_and_never_submitted() {
|
||||
// Live finding: ERPNext's default Banker's Rounding disagrees with Voiced's half-up by a paise on ties.
|
||||
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 - 0.02));
|
||||
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:?}");
|
||||
assert!(r.created && !r.attached, "{r:?}");
|
||||
let msg = r.error.as_deref().unwrap();
|
||||
assert!(msg.contains("does not submit") && msg.contains("Commercial Rounding"), "{msg}");
|
||||
assert_eq!(hits(&m, &submit), 0);
|
||||
assert_eq!(hits(&m, UPLOAD), 0);
|
||||
// The row keeps the remote name, so the draft is recognised later.
|
||||
let row = fx.sync_row(inv.id).unwrap();
|
||||
assert_eq!((row.status.as_str(), row.remote_name.as_str(), row.remote_docstatus), ("conflict", inv.number.as_str(), 0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_large_total_difference_gets_no_rounding_hint() {
|
||||
let fx = Fx::new(|_| {});
|
||||
let inv = fx.issue("Client Ltd");
|
||||
let mut routes = master_routes();
|
||||
routes.push(mirror_create(&inv.number, 0, inv.total + 100.0));
|
||||
let m = mock(routes).await;
|
||||
let r = fx.push(&m, inv.id, None).await;
|
||||
assert!(!r.ok && r.status == "conflict", "{r:?}");
|
||||
assert!(!r.error.as_deref().unwrap().contains("Rounding"), "{r:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_existing_customer_address_is_reused_instead_of_created_again() {
|
||||
let fx = Fx::new(|_| {});
|
||||
let inv = fx.issue("Client Ltd");
|
||||
let mut routes = master_routes();
|
||||
routes.insert(0, route("GET /api/resource/Address?", 200, json!({ "data": [{ "name": "Client Ltd-Billing-3" }] })));
|
||||
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!(hits(&m, ADDRESS_POST), 0);
|
||||
assert_eq!(json_body(&first_request(&m, SI_POST_V2))["customer_address"], "Client Ltd-Billing-3");
|
||||
assert_eq!(fx.scalar("SELECT erpnext_address FROM clients WHERE id = 1").as_deref(), Some("Client Ltd-Billing-3"));
|
||||
let lookup = first_request(&m, "GET /api/resource/Address?");
|
||||
assert!(lookup.contains("address_line1") && lookup.contains("Customer"), "{lookup}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_adopted_invoice_that_already_carries_the_pdf_is_not_attached_twice() {
|
||||
let fx = Fx::new(|_| {});
|
||||
let inv = fx.issue("Client Ltd");
|
||||
let sha = fx.archive(&inv);
|
||||
let mut routes = master_routes();
|
||||
routes.insert(0, route("GET /api/resource/File?", 200, json!({ "data": [{ "name": "f1" }] })));
|
||||
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 } }),
|
||||
));
|
||||
let m = mock(routes).await;
|
||||
|
||||
let r = fx.push(&m, inv.id, None).await;
|
||||
assert!(r.ok && !r.created && r.attached, "{r:?}");
|
||||
assert_eq!(hits(&m, UPLOAD), 0);
|
||||
assert_eq!(fx.sync_row(inv.id).unwrap().attachment_sha256, sha);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fractional_quantity_refused_by_erpnext_says_what_to_change() {
|
||||
let fx = Fx::new(|_| {});
|
||||
let inv = fx.issue("Client Ltd");
|
||||
let mut routes = master_routes();
|
||||
routes.push(route(
|
||||
SI_POST_V2,
|
||||
417,
|
||||
json!({ "_error_message": "Row 1: Quantity (1.5) cannot be a fraction. To allow this, disable 'Must be Whole Number' in UOM Nos." }),
|
||||
));
|
||||
let m = mock(routes).await;
|
||||
let r = fx.push(&m, inv.id, None).await;
|
||||
let msg = r.error.as_deref().unwrap();
|
||||
assert!(!r.ok && msg.contains("Row 1: Quantity (1.5) cannot be a fraction") && msg.contains("unit mapping"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reverse_charge_invoice_is_sent_as_a_normal_one_with_a_warning() {
|
||||
let fx = Fx::new(|_| {});
|
||||
let inv = fx.issue("Client Ltd");
|
||||
fx.db.lock().unwrap().execute("UPDATE invoices SET reverse_charge = 1 WHERE id = ?1", params![inv.id]).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, None).await;
|
||||
assert!(r.ok, "{r:?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings);
|
||||
assert_eq!(json_body(&first_request(&m, SI_POST_V2))["is_reverse_charge"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_mirrored_number_india_compliance_would_refuse_is_stopped_before_anything_is_created() {
|
||||
let fx = Fx::new(|_| {});
|
||||
let inv = fx.issue("Client Ltd");
|
||||
fx.db.lock().unwrap().execute("UPDATE invoices SET number = 'IMPORTED/2026-INVOICE-0001' WHERE id = ?1", params![inv.id]).unwrap();
|
||||
let mut routes = master_routes();
|
||||
routes.push(mirror_create("IMPORTED/2026-INVOICE-0001", 0, inv.total));
|
||||
let m = mock(routes).await;
|
||||
let r = fx.push(&m, inv.id, None).await;
|
||||
assert!(!r.ok && r.error_kind == Some(ErrorKind::Precondition), "{r:?}");
|
||||
assert!(r.error.as_deref().unwrap().contains("16 characters"), "{r:?}");
|
||||
assert_eq!(hits(&m, SI_POST_V2), 0);
|
||||
|
||||
// Series naming lets ERPNext pick the name, so the same number is fine there.
|
||||
let fx = Fx::new(|c| {
|
||||
c.naming_mode = NamingMode::Series;
|
||||
c.naming_series = "SINV-.YY.-".into();
|
||||
});
|
||||
let inv = fx.issue("Client Ltd");
|
||||
fx.db.lock().unwrap().execute("UPDATE invoices SET number = 'IMPORTED/2026-INVOICE-0001' WHERE id = ?1", params![inv.id]).unwrap();
|
||||
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-00001", "docstatus": 0, "grand_total": inv.total } })));
|
||||
let m = mock(routes).await;
|
||||
let r = fx.push(&m, inv.id, None).await;
|
||||
assert!(r.ok, "{r:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mirrored_409_with_the_same_total_is_recorded_as_synced() {
|
||||
let fx = Fx::new(|_| {});
|
||||
@@ -827,6 +960,20 @@ async fn tds_account_is_required_only_for_payments_with_tds_and_the_bank_always(
|
||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("payment bank account"), "{r:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_tds_only_payment_is_refused_locally_with_a_readable_reason() {
|
||||
// Live finding: ERPNext's own answer to a zero paid amount is just "Paid Amount is mandatory".
|
||||
let fx = Fx::new(|_| {});
|
||||
let inv = fx.issue("Client Ltd");
|
||||
mark_invoice_synced(&fx, &inv, 1);
|
||||
let pid = add_payment(&fx, &inv, 0, 100_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("TDS only"), "{r:?}");
|
||||
assert!(m.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn payment_errors_come_back_readable_and_leave_the_payment_unlinked() {
|
||||
let fx = Fx::new(|_| {});
|
||||
@@ -846,7 +993,7 @@ async fn payment_errors_come_back_readable_and_leave_the_payment_unlinked() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_entry_mapping_is_pure_and_covers_the_unverified_deduction_fields() {
|
||||
fn payment_entry_mapping_is_pure_and_matches_the_shape_verified_on_a_live_site() {
|
||||
let input = |tds: i64, reference: &'static str| PaymentEntryInput {
|
||||
payment_id: 7,
|
||||
invoice_number: "AP/2026-001",
|
||||
|
||||
Reference in New Issue
Block a user