style(src-tauri): cargo fmt
Pure mechanical rustfmt reformat (default config, no rustfmt.toml).
This commit is contained in:
@@ -9,8 +9,8 @@ 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,
|
||||
SALES_INVOICE_V2,
|
||||
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker,
|
||||
InvoiceContext, Vendor, SALES_INVOICE_V2,
|
||||
};
|
||||
use crate::commands::archive::read_archive_impl;
|
||||
use crate::commands::invoice::get_invoice_impl;
|
||||
@@ -31,14 +31,20 @@ use std::sync::Mutex;
|
||||
type Db = Mutex<Connection>;
|
||||
|
||||
const DOCTYPE_INVOICE: &str = "Sales Invoice";
|
||||
const GET_PAYMENT_ENTRY: &str = "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
|
||||
const GET_PAYMENT_ENTRY: &str =
|
||||
"erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
|
||||
|
||||
fn pre(message: impl Into<String>) -> ErpError {
|
||||
ErpError::new(ErrorKind::Precondition, message)
|
||||
}
|
||||
|
||||
fn with_db<T>(db: &Db, f: impl FnOnce(&mut Connection) -> Result<T, String>) -> Result<T, ErpError> {
|
||||
let mut conn = db.lock().map_err(|e| pre(format!("The database is busy: {e}")))?;
|
||||
fn with_db<T>(
|
||||
db: &Db,
|
||||
f: impl FnOnce(&mut Connection) -> Result<T, String>,
|
||||
) -> Result<T, ErpError> {
|
||||
let mut conn = db
|
||||
.lock()
|
||||
.map_err(|e| pre(format!("The database is busy: {e}")))?;
|
||||
f(&mut conn).map_err(|e| pre(format!("Could not read or save the sync state: {e}")))
|
||||
}
|
||||
|
||||
@@ -197,7 +203,11 @@ pub fn sync_statuses(conn: &Connection) -> Result<Vec<SyncStatus>, String> {
|
||||
|
||||
/// `<base>/app/sales-invoice/<name>`, with the name URL-encoded (a mirrored number contains a slash).
|
||||
pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
|
||||
const KEEP: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~');
|
||||
const KEEP: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'_')
|
||||
.remove(b'.')
|
||||
.remove(b'~');
|
||||
let row = load_sync(conn, invoice_id)?;
|
||||
let name = row.map(|r| r.remote_name).unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
@@ -205,7 +215,10 @@ pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
|
||||
}
|
||||
let cfg = config::load(conn)?;
|
||||
let base = super::client::normalize_base_url(&cfg.base_url).map_err(|e| e.to_string())?;
|
||||
Ok(format!("{base}/app/sales-invoice/{}", utf8_percent_encode(&name, KEEP)))
|
||||
Ok(format!(
|
||||
"{base}/app/sales-invoice/{}",
|
||||
utf8_percent_encode(&name, KEEP)
|
||||
))
|
||||
}
|
||||
|
||||
// ---- loading ----
|
||||
@@ -294,7 +307,12 @@ fn load_client(conn: &Connection, inv: &Invoice) -> Result<ClientRow, String> {
|
||||
address_line2: String::new(),
|
||||
city: String::new(),
|
||||
pincode: String::new(),
|
||||
gst_category: if has_gstin { "registered_regular" } else { "unregistered" }.into(),
|
||||
gst_category: if has_gstin {
|
||||
"registered_regular"
|
||||
} else {
|
||||
"unregistered"
|
||||
}
|
||||
.into(),
|
||||
default_notes: String::new(),
|
||||
payment_terms_days: None,
|
||||
invoice_count: 0,
|
||||
@@ -321,7 +339,11 @@ fn load_item_codes(conn: &Connection, inv: &Invoice) -> Result<Vec<Option<String
|
||||
// ORDER BY id DESC: the oldest preset wins on a repeated description.
|
||||
by_desc.insert(desc.trim().to_lowercase(), code.trim().to_string());
|
||||
}
|
||||
Ok(inv.items.iter().map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned()).collect())
|
||||
Ok(inv
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// True when a connection test result is stored (blank, `null` and `{}` count as none).
|
||||
@@ -353,8 +375,11 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
|
||||
}
|
||||
}
|
||||
let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?;
|
||||
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot)
|
||||
.ok_or_else(|| fail(pre(format!("Invoice {number} has no supplier details recorded, so it cannot be sent."))))?;
|
||||
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot).ok_or_else(|| {
|
||||
fail(pre(format!(
|
||||
"Invoice {number} has no supplier details recorded, so it cannot be sent."
|
||||
)))
|
||||
})?;
|
||||
// A registered vendor's invoices go out with India Compliance fields, and those depend on what the
|
||||
// connection test detected, so refuse until the test has run once.
|
||||
if vendor.registered && !has_detect_result(&cfg.last_detect_result) {
|
||||
@@ -382,7 +407,17 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
|
||||
.ok()
|
||||
.and_then(|v| v.get("indiaCompliance").and_then(Value::as_bool))
|
||||
.unwrap_or(false);
|
||||
Ok(Loaded { cfg, invoice, vendor, client, item_codes, sync, pdf, pdf_warning, india_compliance })
|
||||
Ok(Loaded {
|
||||
cfg,
|
||||
invoice,
|
||||
vendor,
|
||||
client,
|
||||
item_codes,
|
||||
sync,
|
||||
pdf,
|
||||
pdf_warning,
|
||||
india_compliance,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- address checks (local, before anything is sent) ----
|
||||
@@ -430,7 +465,10 @@ fn gstin_applies(client: &Client) -> bool {
|
||||
let g = client.gstin.trim();
|
||||
!g.is_empty()
|
||||
&& !g.eq_ignore_ascii_case("NA")
|
||||
&& matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez")
|
||||
&& matches!(
|
||||
client.gst_category.as_str(),
|
||||
"registered_regular" | "composition" | "sez"
|
||||
)
|
||||
}
|
||||
|
||||
/// The checks India Compliance would make on an Address, done here so the error is readable and local.
|
||||
@@ -440,7 +478,10 @@ pub fn validate_address(client: &Client) -> Result<(), String> {
|
||||
let pin = client.pincode.trim();
|
||||
if !pin.is_empty() {
|
||||
if pin.len() != 6 || !pin.bytes().all(|b| b.is_ascii_digit()) || pin.starts_with('0') {
|
||||
return Err(format!("{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.", client.name.trim()));
|
||||
return Err(format!(
|
||||
"{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.",
|
||||
client.name.trim()
|
||||
));
|
||||
}
|
||||
if let Some(allowed) = pin_prefixes(state_code) {
|
||||
if !allowed.iter().any(|p| pin.starts_with(p)) {
|
||||
@@ -482,7 +523,9 @@ fn doc_docstatus(doc: &Value) -> i64 {
|
||||
|
||||
fn doc_total_paise(doc: &Value) -> Option<i64> {
|
||||
let v = doc.get("grand_total")?;
|
||||
let n = v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?;
|
||||
let n = v
|
||||
.as_f64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?;
|
||||
Some(gst::rupees_to_paise(n))
|
||||
}
|
||||
|
||||
@@ -502,13 +545,26 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
|
||||
"creation asc",
|
||||
)
|
||||
.await?;
|
||||
found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string);
|
||||
found = rows
|
||||
.first()
|
||||
.and_then(|r| r.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
}
|
||||
if found.is_none() {
|
||||
let rows = http
|
||||
.list_resource("Customer", &["name"], json!([["customer_name", "=", name]]), "creation asc")
|
||||
.list_resource(
|
||||
"Customer",
|
||||
&["name"],
|
||||
json!([["customer_name", "=", name]]),
|
||||
"creation asc",
|
||||
)
|
||||
.await?;
|
||||
found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string);
|
||||
found = rows
|
||||
.first()
|
||||
.and_then(|r| r.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
}
|
||||
let customer = match found {
|
||||
Some(c) => c,
|
||||
@@ -521,14 +577,19 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
|
||||
let req = build_customer(client, &l.cfg, l.india_compliance).map_err(pre)?;
|
||||
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
||||
// A duplicate name comes back as "X - 1": always use what the server returned.
|
||||
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new customer's name."))?
|
||||
doc_name(&resp).ok_or_else(|| {
|
||||
ErpError::protocol("ERPNext did not return the new customer's name.")
|
||||
})?
|
||||
}
|
||||
};
|
||||
if let Some(id) = l.client.id {
|
||||
with_db(db, |c| {
|
||||
c.execute("UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2", params![customer, id])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
c.execute(
|
||||
"UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2",
|
||||
params![customer, id],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})?;
|
||||
}
|
||||
Ok(customer)
|
||||
@@ -548,16 +609,27 @@ async fn ensure_address(
|
||||
let Some(client_id) = l.client.id else {
|
||||
return Ok((
|
||||
None,
|
||||
Some("The client is not saved, so the invoice was sent without a customer address.".into()),
|
||||
Some(
|
||||
"The client is not saved, so the invoice was sent without a customer address."
|
||||
.into(),
|
||||
),
|
||||
));
|
||||
};
|
||||
let has_address = [&client.address_line1, &client.address, &client.city, &client.pincode]
|
||||
.iter()
|
||||
.any(|s| !s.trim().is_empty());
|
||||
let has_address = [
|
||||
&client.address_line1,
|
||||
&client.address,
|
||||
&client.city,
|
||||
&client.pincode,
|
||||
]
|
||||
.iter()
|
||||
.any(|s| !s.trim().is_empty());
|
||||
if !has_address {
|
||||
return Ok((
|
||||
None,
|
||||
Some(format!("{} has no address saved, so the invoice was sent without a customer address.", client.name.trim())),
|
||||
Some(format!(
|
||||
"{} has no address saved, so the invoice was sent without a customer address.",
|
||||
client.name.trim()
|
||||
)),
|
||||
));
|
||||
}
|
||||
validate_address(client).map_err(pre)?;
|
||||
@@ -568,13 +640,18 @@ async fn ensure_address(
|
||||
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."))?
|
||||
doc_name(&resp).ok_or_else(|| {
|
||||
ErpError::protocol("ERPNext did not return the new address's name.")
|
||||
})?
|
||||
}
|
||||
};
|
||||
with_db(db, |c| {
|
||||
c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
c.execute(
|
||||
"UPDATE clients SET erpnext_address = ?1 WHERE id = ?2",
|
||||
params![name, client_id],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})?;
|
||||
Ok((Some(name), None))
|
||||
}
|
||||
@@ -593,7 +670,15 @@ async fn find_address(http: &ErpClient, customer: &str, body: &Value) -> Option<
|
||||
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()?;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -656,7 +741,10 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result<Option<RemoteDo
|
||||
.list_resource(
|
||||
DOCTYPE_INVOICE,
|
||||
&["name", "docstatus", "grand_total", "remarks"],
|
||||
json!([["remarks", "like", format!("{marker}%")], ["docstatus", "!=", 2]]),
|
||||
json!([
|
||||
["remarks", "like", format!("{marker}%")],
|
||||
["docstatus", "!=", 2]
|
||||
]),
|
||||
"creation asc",
|
||||
)
|
||||
.await?;
|
||||
@@ -674,7 +762,11 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result<Option<RemoteDo
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltRequest) -> Result<RemoteDoc, ErpError> {
|
||||
async fn create_or_find(
|
||||
http: &ErpClient,
|
||||
l: &Loaded,
|
||||
body: &mapping::BuiltRequest,
|
||||
) -> Result<RemoteDoc, ErpError> {
|
||||
let inv = &l.invoice;
|
||||
if l.cfg.naming_mode == NamingMode::Series {
|
||||
if let Some(found) = find_by_remarks(http, l).await? {
|
||||
@@ -688,10 +780,17 @@ async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltReque
|
||||
(Some(n), _) => n,
|
||||
(None, NamingMode::Mirror) => inv.number.clone(),
|
||||
(None, NamingMode::Series) => {
|
||||
return Err(ErpError::protocol("ERPNext did not return the new Sales Invoice's name."))
|
||||
return Err(ErpError::protocol(
|
||||
"ERPNext did not return the new Sales Invoice's name.",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true, total_paise: doc_total_paise(&doc) })
|
||||
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.
|
||||
@@ -719,11 +818,20 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res
|
||||
http.post(&path, &json!({}), false).await?
|
||||
}
|
||||
NamingMode::Series => {
|
||||
http.post(&["api", "resource", DOCTYPE_INVOICE, name], &json!({ "run_method": "submit" }), false).await?
|
||||
http.post(
|
||||
&["api", "resource", DOCTYPE_INVOICE, name],
|
||||
&json!({ "run_method": "submit" }),
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
// A 2xx without a docstatus is taken as submitted; one that says otherwise is not.
|
||||
match resp.get("data").and_then(|d| d.get("docstatus")).and_then(Value::as_i64) {
|
||||
match resp
|
||||
.get("data")
|
||||
.and_then(|d| d.get("docstatus"))
|
||||
.and_then(Value::as_i64)
|
||||
{
|
||||
Some(1) | None => Ok(1),
|
||||
Some(other) => Err(ErpError::protocol(format!(
|
||||
"ERPNext accepted the submit request but the document is still at docstatus {other}."
|
||||
@@ -734,15 +842,32 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res
|
||||
fn attachment_file_name(number: &str) -> String {
|
||||
let cleaned: String = number
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' })
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let cleaned = cleaned.trim_matches('-');
|
||||
format!("{}.pdf", if cleaned.is_empty() { "invoice" } else { cleaned })
|
||||
format!(
|
||||
"{}.pdf",
|
||||
if cleaned.is_empty() {
|
||||
"invoice"
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
async fn has_attachment(
|
||||
http: &ErpClient,
|
||||
remote_name: &str,
|
||||
file_name: &str,
|
||||
) -> Result<bool, ErpError> {
|
||||
let rows = http
|
||||
.list_resource(
|
||||
"File",
|
||||
@@ -759,9 +884,19 @@ async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) ->
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
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) {
|
||||
if adopted
|
||||
&& has_attachment(http, remote_name, &file_name)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let fields = [
|
||||
@@ -772,14 +907,24 @@ async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf,
|
||||
let resp = http
|
||||
.post_file(
|
||||
&["api", "method", "upload_file"],
|
||||
&Upload { file_name: &file_name, mime: "application/pdf", bytes: &pdf.bytes, fields: &fields },
|
||||
&Upload {
|
||||
file_name: &file_name,
|
||||
mime: "application/pdf",
|
||||
bytes: &pdf.bytes,
|
||||
fields: &fields,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let ok = resp.get("message").map(|m| m.get("name").is_some() || m.get("file_url").is_some()).unwrap_or(false);
|
||||
let ok = resp
|
||||
.get("message")
|
||||
.map(|m| m.get("name").is_some() || m.get("file_url").is_some())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ErpError::protocol("ERPNext did not confirm the PDF upload."))
|
||||
Err(ErpError::protocol(
|
||||
"ERPNext did not confirm the PDF upload.",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,7 +942,14 @@ struct Progress {
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, status: &str, error: &str) -> Result<(), ErpError> {
|
||||
fn persist(
|
||||
db: &Db,
|
||||
invoice_id: i64,
|
||||
prev: Option<&SyncRow>,
|
||||
st: &Progress,
|
||||
status: &str,
|
||||
error: &str,
|
||||
) -> Result<(), ErpError> {
|
||||
let synced_at = if status == "synced" {
|
||||
Some(chrono::Utc::now().to_rfc3339())
|
||||
} else {
|
||||
@@ -818,7 +970,12 @@ fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, stat
|
||||
/// The message shown to the user: which step failed, then the error's own readable text. Local refusals and
|
||||
/// conflicts already say what is wrong and get no prefix.
|
||||
fn failure_text(step: &str, e: &ErpError) -> String {
|
||||
if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) {
|
||||
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.
|
||||
@@ -828,7 +985,13 @@ fn failure_text(step: &str, e: &ErpError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st: &mut Progress) -> Result<(), ErpError> {
|
||||
async fn run_push(
|
||||
db: &Db,
|
||||
http: &ErpClient,
|
||||
l: &Loaded,
|
||||
want_submit: bool,
|
||||
st: &mut Progress,
|
||||
) -> Result<(), ErpError> {
|
||||
let inv = &l.invoice;
|
||||
let cfg = &l.cfg;
|
||||
let prev = l.sync.as_ref();
|
||||
@@ -882,7 +1045,10 @@ 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) {
|
||||
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
|
||||
@@ -911,7 +1077,11 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
||||
if let (Some(w), None) = (&l.pdf_warning, &l.pdf) {
|
||||
st.warnings.push(w.clone());
|
||||
}
|
||||
if existing.is_some() && prev.is_some_and(|p| p.status == "synced") && !need_submit && pdf.is_none() {
|
||||
if existing.is_some()
|
||||
&& prev.is_some_and(|p| p.status == "synced")
|
||||
&& !need_submit
|
||||
&& pdf.is_none()
|
||||
{
|
||||
st.no_op = true;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -933,7 +1103,13 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_id: i64, submit: Option<bool>) -> PushResult {
|
||||
pub async fn push_invoice(
|
||||
db: &Db,
|
||||
local_dir: &Path,
|
||||
http: &ErpClient,
|
||||
invoice_id: i64,
|
||||
submit: Option<bool>,
|
||||
) -> PushResult {
|
||||
let loaded = match load_for_push(db, local_dir, invoice_id) {
|
||||
Ok(l) => l,
|
||||
Err((number, e)) => return PushResult::refused(invoice_id, &number, e),
|
||||
@@ -946,7 +1122,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
|
||||
|
||||
let (error, status): (Option<(ErpError, String)>, &str) = match outcome {
|
||||
Ok(()) => {
|
||||
let write = if st.no_op { Ok(()) } else { persist(db, invoice_id, prev, &st, "synced", "") };
|
||||
let write = if st.no_op {
|
||||
Ok(())
|
||||
} else {
|
||||
persist(db, invoice_id, prev, &st, "synced", "")
|
||||
};
|
||||
match write {
|
||||
Ok(()) => (None, "synced"),
|
||||
Err(e) => {
|
||||
@@ -957,7 +1137,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
|
||||
}
|
||||
Err(e) => {
|
||||
let text = failure_text(st.step, &e);
|
||||
let status = if e.kind == ErrorKind::Conflict { "conflict" } else { "error" };
|
||||
let status = if e.kind == ErrorKind::Conflict {
|
||||
"conflict"
|
||||
} else {
|
||||
"error"
|
||||
};
|
||||
// Best effort: the original error is what the caller needs to see.
|
||||
let _ = persist(db, invoice_id, prev, &st, status, &text);
|
||||
(Some((e, text)), status)
|
||||
@@ -1022,23 +1206,44 @@ pub struct PaymentEntryInput<'a> {
|
||||
/// `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.")?;
|
||||
let mut doc: Map<String, Value> = draft
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or("ERPNext returned an unexpected payment draft.")?;
|
||||
doc.retain(|k, _| !k.starts_with("__"));
|
||||
doc.insert("doctype".into(), json!("Payment Entry"));
|
||||
doc.insert("posting_date".into(), json!(p.paid_on));
|
||||
let reference = if p.reference.trim().is_empty() { format!("Voiced payment {}", p.payment_id) } else { p.reference.trim().to_string() };
|
||||
let reference = if p.reference.trim().is_empty() {
|
||||
format!("Voiced payment {}", p.payment_id)
|
||||
} else {
|
||||
p.reference.trim().to_string()
|
||||
};
|
||||
doc.insert("reference_no".into(), json!(reference));
|
||||
doc.insert("reference_date".into(), json!(p.paid_on));
|
||||
doc.insert("paid_amount".into(), mapping::money(p.cash_paise));
|
||||
doc.insert("received_amount".into(), mapping::money(p.cash_paise));
|
||||
doc.insert("remarks".into(), json!(format!("Voiced payment {} for invoice {}", p.payment_id, p.invoice_number)));
|
||||
doc.insert(
|
||||
"remarks".into(),
|
||||
json!(format!(
|
||||
"Voiced payment {} for invoice {}",
|
||||
p.payment_id, p.invoice_number
|
||||
)),
|
||||
);
|
||||
|
||||
let allocated = mapping::money(p.cash_paise + p.tds_paise);
|
||||
let refs = doc.get_mut("references").and_then(Value::as_array_mut).ok_or("ERPNext returned no invoice reference for this payment.")?;
|
||||
let refs = doc
|
||||
.get_mut("references")
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("ERPNext returned no invoice reference for this payment.")?;
|
||||
let target = refs
|
||||
.iter_mut()
|
||||
.find(|r| r.get("reference_name").and_then(Value::as_str) == Some(p.remote_invoice))
|
||||
.ok_or_else(|| format!("ERPNext's payment draft does not reference {}.", p.remote_invoice))?;
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"ERPNext's payment draft does not reference {}.",
|
||||
p.remote_invoice
|
||||
)
|
||||
})?;
|
||||
target["allocated_amount"] = allocated;
|
||||
|
||||
let deductions = if p.tds_paise > 0 {
|
||||
@@ -1100,7 +1305,11 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "Payment not found".to_string())?;
|
||||
let number: String = conn
|
||||
.query_row("SELECT number FROM invoices WHERE id = ?1", params![row.invoice_id], |r| r.get(0))
|
||||
.query_row(
|
||||
"SELECT number FROM invoices WHERE id = ?1",
|
||||
params![row.invoice_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let sync = load_sync(conn, row.invoice_id)?;
|
||||
let cfg = config::load(conn)?;
|
||||
@@ -1144,21 +1353,33 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
|
||||
));
|
||||
}
|
||||
if cfg.payment_bank_account.trim().is_empty() {
|
||||
return fail(pre("Set the payment bank account in the ERPNext settings first."));
|
||||
return fail(pre(
|
||||
"Set the payment bank account in the ERPNext settings first.",
|
||||
));
|
||||
}
|
||||
if row.tds_paise > 0 && cfg.tds_account.trim().is_empty() {
|
||||
return fail(pre("This payment has TDS: set the TDS account in the ERPNext settings first."));
|
||||
return fail(pre(
|
||||
"This payment has TDS: set the TDS account in the ERPNext settings first.",
|
||||
));
|
||||
}
|
||||
|
||||
let query = [
|
||||
("dt", DOCTYPE_INVOICE.to_string()),
|
||||
("dn", remote_invoice.clone()),
|
||||
("bank_account", cfg.payment_bank_account.trim().to_string()),
|
||||
("party_amount", paise_to_decimal(row.amount_paise + row.tds_paise)),
|
||||
(
|
||||
"party_amount",
|
||||
paise_to_decimal(row.amount_paise + row.tds_paise),
|
||||
),
|
||||
];
|
||||
let draft = match http.get(&["api", "method", GET_PAYMENT_ENTRY], &query).await {
|
||||
let draft = match http
|
||||
.get(&["api", "method", GET_PAYMENT_ENTRY], &query)
|
||||
.await
|
||||
{
|
||||
Ok(v) => v.get("message").cloned().unwrap_or(Value::Null),
|
||||
Err(e) => return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e),
|
||||
Err(e) => {
|
||||
return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e)
|
||||
}
|
||||
};
|
||||
let body = match build_payment_entry(
|
||||
&draft,
|
||||
@@ -1178,17 +1399,27 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
|
||||
Err(e) => return fail(ErpError::protocol(e)),
|
||||
};
|
||||
// Not retried after a 5xx or timeout: it may have been created, and a second entry would double-count.
|
||||
let resp = match http.post(&["api", "resource", "Payment Entry"], &body, false).await {
|
||||
let resp = match http
|
||||
.post(&["api", "resource", "Payment Entry"], &body, false)
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e),
|
||||
Err(e) => {
|
||||
return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e)
|
||||
}
|
||||
};
|
||||
let Some(name) = doc_name(&resp) else {
|
||||
return fail(ErpError::protocol("ERPNext did not return the new Payment Entry's name."));
|
||||
return fail(ErpError::protocol(
|
||||
"ERPNext did not return the new Payment Entry's name.",
|
||||
));
|
||||
};
|
||||
if let Err(e) = with_db(db, |c| {
|
||||
c.execute("UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2", params![name, payment_id])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
c.execute(
|
||||
"UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2",
|
||||
params![name, payment_id],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -1223,7 +1454,14 @@ impl InvoiceSink for ErpnextSink<'_> {
|
||||
request: PushRequest,
|
||||
) -> impl std::future::Future<Output = Result<PushedInvoice, String>> + Send + 'a {
|
||||
async move {
|
||||
let r = push_invoice(self.db, self.local_dir, self.http, request.invoice_id, request.submit).await;
|
||||
let r = push_invoice(
|
||||
self.db,
|
||||
self.local_dir,
|
||||
self.http,
|
||||
request.invoice_id,
|
||||
request.submit,
|
||||
)
|
||||
.await;
|
||||
if r.ok {
|
||||
Ok(PushedInvoice {
|
||||
remote_name: r.remote_name,
|
||||
@@ -1244,7 +1482,10 @@ impl InvoiceSink for ErpnextSink<'_> {
|
||||
async move {
|
||||
let r = push_payment(self.db, self.http, request.payment_id).await;
|
||||
match (r.ok, r.entry_name) {
|
||||
(true, Some(name)) => Ok(PushedPayment { remote_name: name, created: !r.already_synced }),
|
||||
(true, Some(name)) => Ok(PushedPayment {
|
||||
remote_name: name,
|
||||
created: !r.already_synced,
|
||||
}),
|
||||
_ => Err(r.error.unwrap_or_else(|| "The payment push failed.".into())),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user