style(src-tauri): cargo fmt

Pure mechanical rustfmt reformat (default config, no rustfmt.toml).
This commit is contained in:
soubarna
2026-10-05 17:19:13 +05:30
parent e1031b3ecc
commit b6cf46b883
27 changed files with 4430 additions and 1042 deletions
+314 -73
View File
@@ -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())),
}
}