ci: add quality gates (semgrep, lint, e2e smoke, advisory PR-Agent) #1
@@ -30,7 +30,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
|||||||
|
|
||||||
/// The hash comes from the database, but it ends up in a file path, so check its shape.
|
/// The hash comes from the database, but it ends up in a file path, so check its shape.
|
||||||
fn archive_path(local_dir: &Path, sha256: &str) -> Result<PathBuf, String> {
|
fn archive_path(local_dir: &Path, sha256: &str) -> Result<PathBuf, String> {
|
||||||
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
let valid = sha256.len() == 64
|
||||||
|
&& sha256
|
||||||
|
.bytes()
|
||||||
|
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
||||||
if !valid {
|
if !valid {
|
||||||
return Err("The stored archive hash is malformed".to_string());
|
return Err("The stored archive hash is malformed".to_string());
|
||||||
}
|
}
|
||||||
@@ -72,7 +75,9 @@ pub fn archive_pdf_impl(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
if status != "issued" && status != "cancelled" {
|
if status != "issued" && status != "cancelled" {
|
||||||
return Err(format!("Only an issued invoice can be archived (this one is {status})"));
|
return Err(format!(
|
||||||
|
"Only an issued invoice can be archived (this one is {status})"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let sha = sha256_hex(bytes);
|
let sha = sha256_hex(bytes);
|
||||||
@@ -89,7 +94,9 @@ pub fn archive_pdf_impl(
|
|||||||
std::fs::create_dir_all(path.parent().expect("archive path has a parent"))
|
std::fs::create_dir_all(path.parent().expect("archive path has a parent"))
|
||||||
.map_err(|e| format!("Could not create the archive folder: {e}"))?;
|
.map_err(|e| format!("Could not create the archive folder: {e}"))?;
|
||||||
// Same content, same name; a matching length means the file is already in place.
|
// Same content, same name; a matching length means the file is already in place.
|
||||||
let present = std::fs::metadata(&path).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);
|
let present = std::fs::metadata(&path)
|
||||||
|
.map(|m| m.len() == bytes.len() as u64)
|
||||||
|
.unwrap_or(false);
|
||||||
if !present {
|
if !present {
|
||||||
write_atomic(&path, bytes)?;
|
write_atomic(&path, bytes)?;
|
||||||
}
|
}
|
||||||
@@ -107,7 +114,8 @@ pub fn archive_pdf_impl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result<Vec<u8>, String> {
|
pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result<Vec<u8>, String> {
|
||||||
let sha = stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?;
|
let sha =
|
||||||
|
stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?;
|
||||||
let path = archive_path(local_dir, &sha)?;
|
let path = archive_path(local_dir, &sha)?;
|
||||||
let bytes = std::fs::read(&path).map_err(|e| {
|
let bytes = std::fs::read(&path).map_err(|e| {
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
@@ -124,7 +132,11 @@ pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result
|
|||||||
|
|
||||||
fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> {
|
fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> {
|
||||||
let row: Option<Option<String>> = conn
|
let row: Option<Option<String>> = conn
|
||||||
.query_row("SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
row.ok_or_else(|| "Invoice not found".to_string())
|
row.ok_or_else(|| "Invoice not found".to_string())
|
||||||
@@ -140,12 +152,20 @@ pub fn archive_status_impl(conn: &Connection, id: i64) -> Result<ArchiveStatus,
|
|||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
Ok(ArchiveStatus { archived: sha256.is_some(), sha256, fingerprint, archived_at })
|
Ok(ArchiveStatus {
|
||||||
|
archived: sha256.is_some(),
|
||||||
|
sha256,
|
||||||
|
fingerprint,
|
||||||
|
archived_at,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread.
|
// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn archive_pdf(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
pub async fn archive_pdf(
|
||||||
|
request: Request<'_>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
let bytes = raw_body(&request)?;
|
let bytes = raw_body(&request)?;
|
||||||
let headers = header_map(&request);
|
let headers = header_map(&request);
|
||||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -159,7 +179,10 @@ pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result<ArchiveStatus, String> {
|
pub async fn archive_status(
|
||||||
|
invoice_id: i64,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ArchiveStatus, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
archive_status_impl(&conn, invoice_id)
|
archive_status_impl(&conn, invoice_id)
|
||||||
}
|
}
|
||||||
@@ -195,7 +218,10 @@ mod tests {
|
|||||||
let mut conn = conn_with_invoice("issued");
|
let mut conn = conn_with_invoice("issued");
|
||||||
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
assert_eq!(sha.len(), 64);
|
assert_eq!(sha.len(), 64);
|
||||||
assert_eq!(std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), PDF);
|
assert_eq!(
|
||||||
|
std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(),
|
||||||
|
PDF
|
||||||
|
);
|
||||||
|
|
||||||
let status = archive_status_impl(&conn, 1).unwrap();
|
let status = archive_status_impl(&conn, 1).unwrap();
|
||||||
assert!(status.archived);
|
assert!(status.archived);
|
||||||
@@ -205,7 +231,10 @@ mod tests {
|
|||||||
|
|
||||||
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
assert_eq!(again, sha);
|
assert_eq!(again, sha);
|
||||||
assert_eq!(archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), first_at);
|
assert_eq!(
|
||||||
|
archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(),
|
||||||
|
first_at
|
||||||
|
);
|
||||||
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,19 +250,27 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = conn_with_invoice("issued");
|
let mut conn = conn_with_invoice("issued");
|
||||||
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
let err = archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err();
|
let err =
|
||||||
|
archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err();
|
||||||
assert!(err.contains("already has an archived original"), "{err}");
|
assert!(err.contains("already has an archived original"), "{err}");
|
||||||
assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha);
|
assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha);
|
||||||
assert_eq!(std::fs::read_dir(dir.path().join("archive")).unwrap().count(), 1);
|
assert_eq!(
|
||||||
|
std::fs::read_dir(dir.path().join("archive"))
|
||||||
|
.unwrap()
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_pdf_and_oversize_bodies_are_refused() {
|
fn non_pdf_and_oversize_bodies_are_refused() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = conn_with_invoice("issued");
|
let mut conn = conn_with_invoice("issued");
|
||||||
assert!(archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
assert!(
|
||||||
|
archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("not a PDF"));
|
.contains("not a PDF")
|
||||||
|
);
|
||||||
let mut big = b"%PDF-".to_vec();
|
let mut big = b"%PDF-".to_vec();
|
||||||
big.resize(MAX_ARCHIVE_BYTES + 1, 0);
|
big.resize(MAX_ARCHIVE_BYTES + 1, 0);
|
||||||
assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1"))
|
assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1"))
|
||||||
@@ -264,15 +301,21 @@ mod tests {
|
|||||||
fn read_archive_detects_corruption_and_missing_files() {
|
fn read_archive_detects_corruption_and_missing_files() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = conn_with_invoice("issued");
|
let mut conn = conn_with_invoice("issued");
|
||||||
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("no archived PDF"));
|
assert!(read_archive_impl(&conn, dir.path(), 1)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("no archived PDF"));
|
||||||
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
let file = dir.path().join("archive").join(format!("{sha}.pdf"));
|
let file = dir.path().join("archive").join(format!("{sha}.pdf"));
|
||||||
|
|
||||||
std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap();
|
std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap();
|
||||||
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("corrupted"));
|
assert!(read_archive_impl(&conn, dir.path(), 1)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("corrupted"));
|
||||||
|
|
||||||
std::fs::remove_file(&file).unwrap();
|
std::fs::remove_file(&file).unwrap();
|
||||||
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("missing"));
|
assert!(read_archive_impl(&conn, dir.path(), 1)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("missing"));
|
||||||
// Archiving the same bytes again restores the file.
|
// Archiving the same bytes again restores the file.
|
||||||
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||||
@@ -282,8 +325,13 @@ mod tests {
|
|||||||
fn a_malformed_stored_hash_never_becomes_a_path() {
|
fn a_malformed_stored_hash_never_becomes_a_path() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let conn = conn_with_invoice("issued");
|
let conn = conn_with_invoice("issued");
|
||||||
conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", [])
|
conn.execute(
|
||||||
|
"UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed"));
|
assert!(read_archive_impl(&conn, dir.path(), 1)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("malformed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
|
|||||||
if raw.components().any(|c| matches!(c, Component::ParentDir)) {
|
if raw.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||||
return Err("Asset path must not contain '..'".into());
|
return Err("Asset path must not contain '..'".into());
|
||||||
}
|
}
|
||||||
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
|
let full = if raw.is_absolute() {
|
||||||
|
raw.to_path_buf()
|
||||||
|
} else {
|
||||||
|
data_dir.join(raw)
|
||||||
|
};
|
||||||
let root = data_dir
|
let root = data_dir
|
||||||
.join("assets")
|
.join("assets")
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
@@ -73,7 +77,12 @@ pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result<String, String
|
|||||||
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
|
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
|
||||||
let (ext, _) = sniff_image(bytes)?;
|
let (ext, _) = sniff_image(bytes)?;
|
||||||
let dir = assets_dir(data_dir)?;
|
let dir = assets_dir(data_dir)?;
|
||||||
let file = format!("{}-{}.{}", safe_kind(kind), uuid::Uuid::new_v4().simple(), ext);
|
let file = format!(
|
||||||
|
"{}-{}.{}",
|
||||||
|
safe_kind(kind),
|
||||||
|
uuid::Uuid::new_v4().simple(),
|
||||||
|
ext
|
||||||
|
);
|
||||||
std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?;
|
std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?;
|
||||||
Ok(format!("assets/{file}"))
|
Ok(format!("assets/{file}"))
|
||||||
}
|
}
|
||||||
@@ -105,7 +114,11 @@ fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
|
|||||||
|
|
||||||
fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> {
|
fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> {
|
||||||
let raw = Path::new(path);
|
let raw = Path::new(path);
|
||||||
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
|
let full = if raw.is_absolute() {
|
||||||
|
raw.to_path_buf()
|
||||||
|
} else {
|
||||||
|
data_dir.join(raw)
|
||||||
|
};
|
||||||
// Removing something that is already gone is fine, but an escape attempt is not.
|
// Removing something that is already gone is fine, but an escape attempt is not.
|
||||||
if std::fs::symlink_metadata(&full).is_err() {
|
if std::fs::symlink_metadata(&full).is_err() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -163,7 +176,10 @@ mod tests {
|
|||||||
fn sniffing_accepts_only_png_and_jpeg() {
|
fn sniffing_accepts_only_png_and_jpeg() {
|
||||||
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
|
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
|
||||||
assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg"));
|
assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg"));
|
||||||
assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image");
|
assert_eq!(
|
||||||
|
sniff_image(b"GIF89a....").unwrap_err(),
|
||||||
|
"Use a PNG or JPEG image"
|
||||||
|
);
|
||||||
assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err());
|
assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err());
|
||||||
assert!(sniff_image(b"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err());
|
assert!(sniff_image(b"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err());
|
||||||
assert!(sniff_image(b"").is_err());
|
assert!(sniff_image(b"").is_err());
|
||||||
@@ -173,7 +189,10 @@ mod tests {
|
|||||||
fn write_ignores_the_name_and_stores_a_relative_path() {
|
fn write_ignores_the_name_and_stores_a_relative_path() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let stored = write_asset(dir.path(), "Logo", JPEG).unwrap();
|
let stored = write_asset(dir.path(), "Logo", JPEG).unwrap();
|
||||||
assert!(stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), "{stored}");
|
assert!(
|
||||||
|
stored.starts_with("assets/logo-") && stored.ends_with(".jpg"),
|
||||||
|
"{stored}"
|
||||||
|
);
|
||||||
assert!(dir.path().join(&stored).is_file());
|
assert!(dir.path().join(&stored).is_file());
|
||||||
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
|
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
|
||||||
let uri = asset_data_uri(dir.path(), &stored).unwrap();
|
let uri = asset_data_uri(dir.path(), &stored).unwrap();
|
||||||
@@ -210,7 +229,10 @@ mod tests {
|
|||||||
let absolute = dir.path().join(&stored);
|
let absolute = dir.path().join(&stored);
|
||||||
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
|
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
|
||||||
assert_eq!(by_relative, by_absolute);
|
assert_eq!(by_relative, by_absolute);
|
||||||
assert_eq!(relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), stored);
|
assert_eq!(
|
||||||
|
relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(),
|
||||||
|
stored
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+492
-121
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,16 @@ use crate::AppState;
|
|||||||
use rusqlite::{params, Connection, Row};
|
use rusqlite::{params, Connection, Row};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"];
|
const CATEGORIES: &[&str] = &[
|
||||||
|
"registered_regular",
|
||||||
|
"unregistered",
|
||||||
|
"composition",
|
||||||
|
"sez",
|
||||||
|
"overseas",
|
||||||
|
];
|
||||||
|
|
||||||
const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
|
const CLIENT_COLS: &str =
|
||||||
|
"c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
|
||||||
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
|
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
|
||||||
c.payment_terms_days,
|
c.payment_terms_days,
|
||||||
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
|
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
|
||||||
@@ -33,7 +40,13 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
|||||||
|
|
||||||
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
|
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
|
||||||
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
|
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
|
||||||
pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String {
|
pub fn compose_address(
|
||||||
|
line1: &str,
|
||||||
|
line2: &str,
|
||||||
|
city: &str,
|
||||||
|
state_code: &str,
|
||||||
|
pincode: &str,
|
||||||
|
) -> String {
|
||||||
let state_name = gst::STATES
|
let state_name = gst::STATES
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(c, _)| *c == state_code)
|
.find(|(c, _)| *c == state_code)
|
||||||
@@ -246,7 +259,10 @@ mod tests {
|
|||||||
c.po_number = "PO-9".into();
|
c.po_number = "PO-9".into();
|
||||||
let saved = save_client_impl(&conn, c).unwrap();
|
let saved = save_client_impl(&conn, c).unwrap();
|
||||||
assert!(saved.id.is_some());
|
assert!(saved.id.is_some());
|
||||||
assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
assert_eq!(
|
||||||
|
saved.address,
|
||||||
|
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
|
||||||
|
);
|
||||||
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
|
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
|
||||||
let listed = list_clients_impl(&conn).unwrap();
|
let listed = list_clients_impl(&conn).unwrap();
|
||||||
assert_eq!(listed.len(), 1);
|
assert_eq!(listed.len(), 1);
|
||||||
@@ -282,7 +298,10 @@ mod tests {
|
|||||||
edit.name = "B".into();
|
edit.name = "B".into();
|
||||||
edit.gst_category = "sez".into();
|
edit.gst_category = "sez".into();
|
||||||
let again = save_client_impl(&conn, edit).unwrap();
|
let again = save_client_impl(&conn, edit).unwrap();
|
||||||
assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez"));
|
assert_eq!(
|
||||||
|
(again.name.as_str(), again.gst_category.as_str()),
|
||||||
|
("B", "sez")
|
||||||
|
);
|
||||||
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||||
let mut ghost = blank("Ghost");
|
let mut ghost = blank("Ghost");
|
||||||
ghost.id = Some(999);
|
ghost.id = Some(999);
|
||||||
@@ -292,7 +311,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validation_rejects_bad_input() {
|
fn validation_rejects_bad_input() {
|
||||||
let conn = open_in_memory().unwrap();
|
let conn = open_in_memory().unwrap();
|
||||||
assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name"));
|
assert!(save_client_impl(&conn, blank(" "))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("name"));
|
||||||
|
|
||||||
let mut c = blank("X");
|
let mut c = blank("X");
|
||||||
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
|
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
|
||||||
@@ -342,9 +363,11 @@ mod tests {
|
|||||||
delete_client_impl(&conn, id).unwrap();
|
delete_client_impl(&conn, id).unwrap();
|
||||||
assert!(list_clients_impl(&conn).unwrap().is_empty());
|
assert!(list_clients_impl(&conn).unwrap().is_empty());
|
||||||
let (name, addr): (String, String) = conn
|
let (name, addr): (String, String) = conn
|
||||||
.query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
"SELECT client_name, client_address FROM invoices WHERE number='T-1'",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
|
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,12 @@
|
|||||||
//! never held across a network call.
|
//! never held across a network call.
|
||||||
|
|
||||||
use crate::integrations::erpnext::client::ErpClient;
|
use crate::integrations::erpnext::client::ErpClient;
|
||||||
use crate::integrations::erpnext::config::{self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView};
|
use crate::integrations::erpnext::config::{
|
||||||
use crate::integrations::erpnext::discovery::{self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts};
|
self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView,
|
||||||
|
};
|
||||||
|
use crate::integrations::erpnext::discovery::{
|
||||||
|
self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts,
|
||||||
|
};
|
||||||
use crate::integrations::erpnext::push::{self, PaymentPushResult, PushResult};
|
use crate::integrations::erpnext::push::{self, PaymentPushResult, PushResult};
|
||||||
use crate::integrations::SyncStatus;
|
use crate::integrations::SyncStatus;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@@ -18,12 +22,18 @@ pub fn get_config_impl(conn: &Connection) -> Result<ErpnextConfigView, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Merges the form with the stored row (blank secret keeps the stored one) without saving.
|
/// Merges the form with the stored row (blank secret keeps the stored one) without saving.
|
||||||
pub fn resolve_config(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfig, String> {
|
pub fn resolve_config(
|
||||||
|
conn: &Connection,
|
||||||
|
input: ErpnextConfigInput,
|
||||||
|
) -> Result<ErpnextConfig, String> {
|
||||||
let stored = config::load(conn)?;
|
let stored = config::load(conn)?;
|
||||||
input.resolve(&stored)
|
input.resolve(&stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfigView, String> {
|
pub fn save_config_impl(
|
||||||
|
conn: &Connection,
|
||||||
|
input: ErpnextConfigInput,
|
||||||
|
) -> Result<ErpnextConfigView, String> {
|
||||||
let cfg = resolve_config(conn, input)?;
|
let cfg = resolve_config(conn, input)?;
|
||||||
config::save(conn, &cfg)?;
|
config::save(conn, &cfg)?;
|
||||||
Ok(cfg.view())
|
Ok(cfg.view())
|
||||||
@@ -32,7 +42,11 @@ pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result<
|
|||||||
/// What the connection test needs from the local database.
|
/// What the connection test needs from the local database.
|
||||||
pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
|
pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
|
||||||
let registration: String = conn
|
let registration: String = conn
|
||||||
.query_row("SELECT gst_registration FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT gst_registration FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT number FROM invoices ORDER BY id")
|
.prepare("SELECT number FROM invoices ORDER BY id")
|
||||||
@@ -54,7 +68,11 @@ pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
|
|||||||
)
|
)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next));
|
.map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next));
|
||||||
Ok(LocalFacts { vendor_registered: registration != "unregistered", invalid_numbers, next_number })
|
Ok(LocalFacts {
|
||||||
|
vendor_registered: registration != "unregistered",
|
||||||
|
invalid_numbers,
|
||||||
|
next_number,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -65,7 +83,10 @@ pub fn erpnext_get_config(state: State<AppState>) -> Result<ErpnextConfigView, S
|
|||||||
|
|
||||||
/// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it.
|
/// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn erpnext_save_config(state: State<AppState>, config: ErpnextConfigInput) -> Result<ErpnextConfigView, String> {
|
pub fn erpnext_save_config(
|
||||||
|
state: State<AppState>,
|
||||||
|
config: ErpnextConfigInput,
|
||||||
|
) -> Result<ErpnextConfigView, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
save_config_impl(&conn, config)
|
save_config_impl(&conn, config)
|
||||||
}
|
}
|
||||||
@@ -105,7 +126,10 @@ fn saved_client(state: &AppState) -> Result<ErpClient, String> {
|
|||||||
config::load(&conn)?
|
config::load(&conn)?
|
||||||
};
|
};
|
||||||
if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() {
|
if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() {
|
||||||
return Err("ERPNext is not set up yet: enter the address, API key and API secret in Settings.".into());
|
return Err(
|
||||||
|
"ERPNext is not set up yet: enter the address, API key and API secret in Settings."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(ErpClient::from_config(&cfg)?)
|
Ok(ErpClient::from_config(&cfg)?)
|
||||||
}
|
}
|
||||||
@@ -134,7 +158,10 @@ pub async fn erpnext_push_invoices(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn erpnext_push_payment(state: State<'_, AppState>, payment_id: i64) -> Result<PaymentPushResult, String> {
|
pub async fn erpnext_push_payment(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
payment_id: i64,
|
||||||
|
) -> Result<PaymentPushResult, String> {
|
||||||
let http = saved_client(&state)?;
|
let http = saved_client(&state)?;
|
||||||
Ok(push::push_payment(&state.db, &http, payment_id).await)
|
Ok(push::push_payment(&state.db, &http, payment_id).await)
|
||||||
}
|
}
|
||||||
@@ -165,15 +192,22 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
|
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
|
||||||
let mut base = json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" });
|
let mut base =
|
||||||
base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" });
|
||||||
|
base.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.extend(extra.as_object().unwrap().clone());
|
||||||
serde_json::from_value(base).unwrap()
|
serde_json::from_value(base).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn saved_config_is_returned_without_the_secret() {
|
fn saved_config_is_returned_without_the_secret() {
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
let view = save_config_impl(&conn, input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true }))).unwrap();
|
let view = save_config_impl(
|
||||||
|
&conn,
|
||||||
|
input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true })),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(view.api_secret_set);
|
assert!(view.api_secret_set);
|
||||||
let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap();
|
let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap();
|
||||||
assert!(!shown.contains("super-secret-value"));
|
assert!(!shown.contains("super-secret-value"));
|
||||||
@@ -190,7 +224,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_values_are_resolved_without_saving() {
|
fn test_values_are_resolved_without_saving() {
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
let resolved = resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap();
|
let resolved =
|
||||||
|
resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap();
|
||||||
assert_eq!(resolved.api_secret.expose(), "typed-not-saved");
|
assert_eq!(resolved.api_secret.expose(), "typed-not-saved");
|
||||||
assert!(!get_config_impl(&conn).unwrap().api_secret_set);
|
assert!(!get_config_impl(&conn).unwrap().api_secret_set);
|
||||||
}
|
}
|
||||||
@@ -203,7 +238,8 @@ mod tests {
|
|||||||
assert!(facts.invalid_numbers.is_empty());
|
assert!(facts.invalid_numbers.is_empty());
|
||||||
assert!(facts.next_number.is_some());
|
assert!(facts.next_number.is_some());
|
||||||
|
|
||||||
conn.execute("UPDATE app_settings SET gst_registration = 'regular'", []).unwrap();
|
conn.execute("UPDATE app_settings SET gst_registration = 'regular'", [])
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO invoices (number, invoice_date, created_at, updated_at)
|
"INSERT INTO invoices (number, invoice_date, created_at, updated_at)
|
||||||
VALUES ('INV/2026-001', '2026-04-01', 'n', 'n'), ('INVOICE/2026/000001', '2026-04-01', 'n', 'n')",
|
VALUES ('INV/2026-001', '2026-04-01', 'n', 'n'), ('INVOICE/2026/000001', '2026-04-01', 'n', 'n')",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
//! User-visible files: exports written to a path from the save dialog, and the
|
//! User-visible files: exports written to a path from the save dialog, and the
|
||||||
//! reveal/open helpers used afterwards.
|
//! reveal/open helpers used afterwards.
|
||||||
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers};
|
use super::raw::{
|
||||||
|
decode_header_path, header_map, raw_body, required_header, write_atomic, Headers,
|
||||||
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -25,7 +27,9 @@ pub fn write_export_file_impl(
|
|||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
return Err("Nothing to write: the file is empty".to_string());
|
return Err("Nothing to write: the file is empty".to_string());
|
||||||
}
|
}
|
||||||
let parent = path.parent().ok_or_else(|| "The export path has no folder".to_string())?;
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "The export path has no folder".to_string())?;
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||||
write_atomic(&path, bytes)?;
|
write_atomic(&path, bytes)?;
|
||||||
@@ -38,7 +42,11 @@ pub fn write_export_file_impl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_last_export_dir_impl(conn: &Connection) -> Result<String, String> {
|
pub fn get_last_export_dir_impl(conn: &Connection) -> Result<String, String> {
|
||||||
conn.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
conn.query_row(
|
||||||
|
"SELECT last_export_dir FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +79,11 @@ fn reveal_fallback_command(path: &Path) -> Command {
|
|||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
|
let folder = if path.is_dir() {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
path.parent().unwrap_or(path)
|
||||||
|
};
|
||||||
let mut cmd = Command::new("xdg-open");
|
let mut cmd = Command::new("xdg-open");
|
||||||
cmd.arg(folder);
|
cmd.arg(folder);
|
||||||
cmd
|
cmd
|
||||||
@@ -90,7 +102,9 @@ fn open_fallback_command(path: &Path) -> Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
||||||
let mut child = cmd.spawn().map_err(|e| format!("Could not start the file manager: {e}"))?;
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Could not start the file manager: {e}"))?;
|
||||||
// Reap the child so it does not linger as a zombie; the result is irrelevant
|
// Reap the child so it does not linger as a zombie; the result is irrelevant
|
||||||
// (explorer.exe, for one, exits non-zero even on success).
|
// (explorer.exe, for one, exits non-zero even on success).
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -100,7 +114,10 @@ fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn write_export_file(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
pub async fn write_export_file(
|
||||||
|
request: Request<'_>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
let bytes = raw_body(&request)?;
|
let bytes = raw_body(&request)?;
|
||||||
let headers = header_map(&request);
|
let headers = header_map(&request);
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -125,7 +142,11 @@ pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), Strin
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||||
let path = require_existing(&path)?;
|
let path = require_existing(&path)?;
|
||||||
if app.opener().open_path(path.to_string_lossy(), None::<&str>).is_ok() {
|
if app
|
||||||
|
.opener()
|
||||||
|
.open_path(path.to_string_lossy(), None::<&str>)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
spawn_detached(open_fallback_command(&path))
|
spawn_detached(open_fallback_command(&path))
|
||||||
@@ -141,7 +162,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn encode(path: &Path) -> String {
|
fn encode(path: &Path) -> String {
|
||||||
percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC)
|
percent_encoding::utf8_percent_encode(
|
||||||
|
&path.to_string_lossy(),
|
||||||
|
percent_encoding::NON_ALPHANUMERIC,
|
||||||
|
)
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,8 +176,12 @@ mod tests {
|
|||||||
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
|
||||||
// Non-ASCII, spaces and a folder that does not exist yet.
|
// Non-ASCII, spaces and a folder that does not exist yet.
|
||||||
let target = dir.path().join("New folder").join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf");
|
let target = dir
|
||||||
let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap();
|
.path()
|
||||||
|
.join("New folder")
|
||||||
|
.join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf");
|
||||||
|
let written =
|
||||||
|
write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap();
|
||||||
assert_eq!(PathBuf::from(&written), target);
|
assert_eq!(PathBuf::from(&written), target);
|
||||||
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7");
|
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -170,7 +198,10 @@ mod tests {
|
|||||||
fn rejects_empty_relative_and_missing_paths() {
|
fn rejects_empty_relative_and_missing_paths() {
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] {
|
for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] {
|
||||||
assert!(write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), "{bad}");
|
assert!(
|
||||||
|
write_export_file_impl(&conn, b"x", &headers(bad)).is_err(),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err());
|
assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err());
|
||||||
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
@@ -191,7 +222,9 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
assert!(require_existing("").is_err());
|
assert!(require_existing("").is_err());
|
||||||
let missing = dir.path().join("nope.pdf");
|
let missing = dir.path().join("nope.pdf");
|
||||||
assert!(require_existing(&missing.to_string_lossy()).unwrap_err().contains("does not exist"));
|
assert!(require_existing(&missing.to_string_lossy())
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("does not exist"));
|
||||||
assert!(require_existing(&dir.path().to_string_lossy()).is_ok());
|
assert!(require_existing(&dir.path().to_string_lossy()).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+363
-85
@@ -9,7 +9,9 @@
|
|||||||
//! (`fvar`, `CFF2`: only the default instance would embed), files without outlines, and fonts whose OS/2 `fsType`
|
//! (`fvar`, `CFF2`: only the default instance would embed), files without outlines, and fonts whose OS/2 `fsType`
|
||||||
//! forbids embedding. The sfnt is parsed by hand (no font crate is available offline); only the tables needed
|
//! forbids embedding. The sfnt is parsed by hand (no font crate is available offline); only the tables needed
|
||||||
//! to describe the font are read.
|
//! to describe the font are read.
|
||||||
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers};
|
use super::raw::{
|
||||||
|
decode_header_path, header_map, raw_body, required_header, write_atomic, Headers,
|
||||||
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use flate2::read::ZlibDecoder;
|
use flate2::read::ZlibDecoder;
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
@@ -119,7 +121,10 @@ impl<'a> Sfnt<'a> {
|
|||||||
let off = be32(data, rec + 8).ok_or("The font file is truncated")? as usize;
|
let off = be32(data, rec + 8).ok_or("The font file is truncated")? as usize;
|
||||||
let len = be32(data, rec + 12).ok_or("The font file is truncated")? as usize;
|
let len = be32(data, rec + 12).ok_or("The font file is truncated")? as usize;
|
||||||
if off.checked_add(len).map_or(true, |end| end > data.len()) {
|
if off.checked_add(len).map_or(true, |end| end > data.len()) {
|
||||||
return Err(format!("Table '{}' lies outside the file", String::from_utf8_lossy(&tag)));
|
return Err(format!(
|
||||||
|
"Table '{}' lies outside the file",
|
||||||
|
String::from_utf8_lossy(&tag)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
tables.push((tag, off, len));
|
tables.push((tag, off, len));
|
||||||
}
|
}
|
||||||
@@ -135,7 +140,10 @@ impl<'a> Sfnt<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn utf16be(b: &[u8]) -> String {
|
fn utf16be(b: &[u8]) -> String {
|
||||||
let units: Vec<u16> = b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
let units: Vec<u16> = b
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||||
|
.collect();
|
||||||
String::from_utf16_lossy(&units)
|
String::from_utf16_lossy(&units)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +209,11 @@ impl<'a> Cmap<'a> {
|
|||||||
best = Some((rank, offset, format));
|
best = Some((rank, offset, format));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
best.map(|(_, offset, format)| Self { table, offset, format })
|
best.map(|(_, offset, format)| Self {
|
||||||
|
table,
|
||||||
|
offset,
|
||||||
|
format,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has(&self, cp: u32) -> bool {
|
fn has(&self, cp: u32) -> bool {
|
||||||
@@ -283,7 +295,9 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
|
|||||||
let comp = be32(data, rec + 8).ok_or_else(bad)? as usize;
|
let comp = be32(data, rec + 8).ok_or_else(bad)? as usize;
|
||||||
let orig = be32(data, rec + 12).ok_or_else(bad)? as usize;
|
let orig = be32(data, rec + 12).ok_or_else(bad)? as usize;
|
||||||
let checksum = be32(data, rec + 16).ok_or_else(bad)?;
|
let checksum = be32(data, rec + 16).ok_or_else(bad)?;
|
||||||
let stored = data.get(off..off.checked_add(comp).ok_or_else(bad)?).ok_or_else(bad)?;
|
let stored = data
|
||||||
|
.get(off..off.checked_add(comp).ok_or_else(bad)?)
|
||||||
|
.ok_or_else(bad)?;
|
||||||
total += (orig + 3) & !3;
|
total += (orig + 3) & !3;
|
||||||
if orig > MAX_FONT_BYTES || total > MAX_FONT_BYTES {
|
if orig > MAX_FONT_BYTES || total > MAX_FONT_BYTES {
|
||||||
return Err("The font expands to more than the 16 MB limit".into());
|
return Err("The font expands to more than the 16 MB limit".into());
|
||||||
@@ -293,7 +307,10 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
|
|||||||
} else if comp < orig {
|
} else if comp < orig {
|
||||||
let mut out = Vec::with_capacity(orig);
|
let mut out = Vec::with_capacity(orig);
|
||||||
// One byte over the stated size detects a table that lies about it, without trusting it for allocation.
|
// One byte over the stated size detects a table that lies about it, without trusting it for allocation.
|
||||||
ZlibDecoder::new(stored).take(orig as u64 + 1).read_to_end(&mut out).map_err(|_| bad())?;
|
ZlibDecoder::new(stored)
|
||||||
|
.take(orig as u64 + 1)
|
||||||
|
.read_to_end(&mut out)
|
||||||
|
.map_err(|_| bad())?;
|
||||||
if out.len() != orig {
|
if out.len() != orig {
|
||||||
return Err(bad());
|
return Err(bad());
|
||||||
}
|
}
|
||||||
@@ -301,7 +318,11 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
|
|||||||
} else {
|
} else {
|
||||||
return Err(bad());
|
return Err(bad());
|
||||||
};
|
};
|
||||||
entries.push(Entry { tag, checksum, bytes });
|
entries.push(Entry {
|
||||||
|
tag,
|
||||||
|
checksum,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
entries.sort_by_key(|e| e.tag);
|
entries.sort_by_key(|e| e.tag);
|
||||||
|
|
||||||
@@ -372,12 +393,18 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
|
|||||||
}
|
}
|
||||||
let cff = font.table(b"CFF ").is_some();
|
let cff = font.table(b"CFF ").is_some();
|
||||||
if !cff && (font.table(b"glyf").is_none() || font.table(b"loca").is_none()) {
|
if !cff && (font.table(b"glyf").is_none() || font.table(b"loca").is_none()) {
|
||||||
return Err("The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded.".into());
|
return Err(
|
||||||
|
"The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let head = font.table(b"head").ok_or("The font has no head table")?;
|
let head = font.table(b"head").ok_or("The font has no head table")?;
|
||||||
let cmap_table = font.table(b"cmap").ok_or("The font has no cmap table")?;
|
let cmap_table = font.table(b"cmap").ok_or("The font has no cmap table")?;
|
||||||
let name = font.table(b"name").ok_or("The font has no name table")?;
|
let name = font.table(b"name").ok_or("The font has no name table")?;
|
||||||
let num_glyphs = font.table(b"maxp").and_then(|m| be16(m, 4)).ok_or("The font has no usable maxp table")?;
|
let num_glyphs = font
|
||||||
|
.table(b"maxp")
|
||||||
|
.and_then(|m| be16(m, 4))
|
||||||
|
.ok_or("The font has no usable maxp table")?;
|
||||||
|
|
||||||
let os2 = font.table(b"OS/2");
|
let os2 = font.table(b"OS/2");
|
||||||
let fs_type = os2.and_then(|t| be16(t, 8)).unwrap_or(0);
|
let fs_type = os2.and_then(|t| be16(t, 8)).unwrap_or(0);
|
||||||
@@ -413,7 +440,11 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
|
|||||||
let fs_selection = os2.and_then(|t| be16(t, 62)).unwrap_or(0);
|
let fs_selection = os2.and_then(|t| be16(t, 62)).unwrap_or(0);
|
||||||
let mac_style = be16(head, 44).unwrap_or(0);
|
let mac_style = be16(head, 44).unwrap_or(0);
|
||||||
// fsSelection bit 0 = italic, bit 9 = oblique; head.macStyle bit 1 = italic.
|
// fsSelection bit 0 = italic, bit 9 = oblique; head.macStyle bit 1 = italic.
|
||||||
let italic = if os2.map_or(false, |t| t.len() >= 64) { fs_selection & 0x0201 != 0 } else { mac_style & 0x2 != 0 };
|
let italic = if os2.map_or(false, |t| t.len() >= 64) {
|
||||||
|
fs_selection & 0x0201 != 0
|
||||||
|
} else {
|
||||||
|
mac_style & 0x2 != 0
|
||||||
|
};
|
||||||
|
|
||||||
let cmap = Cmap::new(cmap_table).ok_or("The font has no usable Unicode character map")?;
|
let cmap = Cmap::new(cmap_table).ok_or("The font has no usable Unicode character map")?;
|
||||||
let has_rupee = cmap.has(0x20B9);
|
let has_rupee = cmap.has(0x20B9);
|
||||||
@@ -428,9 +459,14 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
|
|||||||
warnings.push("The font has no rupee sign (\u{20B9}); amounts fall back to IBM Plex Sans for that glyph.".to_string());
|
warnings.push("The font has no rupee sign (\u{20B9}); amounts fall back to IBM Plex Sans for that glyph.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let family = name_string(name, 16).or_else(|| name_string(name, 1)).unwrap_or_default();
|
let family = name_string(name, 16)
|
||||||
let subfamily = name_string(name, 17).or_else(|| name_string(name, 2)).unwrap_or_default();
|
.or_else(|| name_string(name, 1))
|
||||||
let full_name = name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string());
|
.unwrap_or_default();
|
||||||
|
let subfamily = name_string(name, 17)
|
||||||
|
.or_else(|| name_string(name, 2))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let full_name =
|
||||||
|
name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string());
|
||||||
let postscript_name = name_string(name, 6).unwrap_or_default();
|
let postscript_name = name_string(name, 6).unwrap_or_default();
|
||||||
if family.is_empty() && full_name.is_empty() {
|
if family.is_empty() && full_name.is_empty() {
|
||||||
return Err("The font has no readable name".into());
|
return Err("The font has no readable name".into());
|
||||||
@@ -460,7 +496,10 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
|
|||||||
// storage
|
// storage
|
||||||
|
|
||||||
fn font_path(local_dir: &Path, sha256: &str, format: &str) -> Result<PathBuf, String> {
|
fn font_path(local_dir: &Path, sha256: &str, format: &str) -> Result<PathBuf, String> {
|
||||||
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
let valid = sha256.len() == 64
|
||||||
|
&& sha256
|
||||||
|
.bytes()
|
||||||
|
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
||||||
if !valid {
|
if !valid {
|
||||||
return Err("The stored font hash is malformed".to_string());
|
return Err("The stored font hash is malformed".to_string());
|
||||||
}
|
}
|
||||||
@@ -493,19 +532,31 @@ fn map_row(r: &rusqlite::Row) -> rusqlite::Result<UserFont> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_font(conn: &Connection, id: i64) -> Result<Option<UserFont>, String> {
|
fn get_font(conn: &Connection, id: i64) -> Result<Option<UserFont>, String> {
|
||||||
conn.query_row(&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"), params![id], map_row)
|
conn.query_row(
|
||||||
|
&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"),
|
||||||
|
params![id],
|
||||||
|
map_row,
|
||||||
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_user_fonts_impl(conn: &Connection, include_hidden: bool) -> Result<Vec<UserFont>, String> {
|
pub fn list_user_fonts_impl(
|
||||||
|
conn: &Connection,
|
||||||
|
include_hidden: bool,
|
||||||
|
) -> Result<Vec<UserFont>, String> {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {COLS} FROM user_fonts {} ORDER BY face, weight, style, id",
|
"SELECT {COLS} FROM user_fonts {} ORDER BY face, weight, style, id",
|
||||||
if include_hidden { "" } else { "WHERE hidden = 0" }
|
if include_hidden {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"WHERE hidden = 0"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
||||||
let rows = stmt.query_map([], map_row).map_err(|e| e.to_string())?;
|
let rows = stmt.query_map([], map_row).map_err(|e| e.to_string())?;
|
||||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether any invoice's frozen render prefs name this font file.
|
/// Whether any invoice's frozen render prefs name this font file.
|
||||||
@@ -531,7 +582,11 @@ pub struct ImportRequest<'a> {
|
|||||||
fn clean_file_name(raw: &str) -> String {
|
fn clean_file_name(raw: &str) -> String {
|
||||||
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
|
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
|
||||||
let cleaned: String = base.chars().filter(|c| !c.is_control()).take(120).collect();
|
let cleaned: String = base.chars().filter(|c| !c.is_control()).take(120).collect();
|
||||||
if cleaned.trim().is_empty() { "font".to_string() } else { cleaned }
|
if cleaned.trim().is_empty() {
|
||||||
|
"font".to_string()
|
||||||
|
} else {
|
||||||
|
cleaned
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn import_font_impl(
|
pub fn import_font_impl(
|
||||||
@@ -541,7 +596,10 @@ pub fn import_font_impl(
|
|||||||
req: &ImportRequest,
|
req: &ImportRequest,
|
||||||
) -> Result<UserFont, String> {
|
) -> Result<UserFont, String> {
|
||||||
if !FACES.contains(&req.face) {
|
if !FACES.contains(&req.face) {
|
||||||
return Err(format!("Fonts can be imported for {} only", FACES.join(", ")));
|
return Err(format!(
|
||||||
|
"Fonts can be imported for {} only",
|
||||||
|
FACES.join(", ")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !req.licence_acknowledged {
|
if !req.licence_acknowledged {
|
||||||
return Err("Confirm that you hold a licence for this font before importing it".into());
|
return Err("Confirm that you hold a licence for this font before importing it".into());
|
||||||
@@ -567,7 +625,9 @@ pub fn import_font_impl(
|
|||||||
let path = font_path(local_dir, &info.sha256, &info.format)?;
|
let path = font_path(local_dir, &info.sha256, &info.format)?;
|
||||||
std::fs::create_dir_all(path.parent().expect("font path has a parent"))
|
std::fs::create_dir_all(path.parent().expect("font path has a parent"))
|
||||||
.map_err(|e| format!("Could not create the fonts folder: {e}"))?;
|
.map_err(|e| format!("Could not create the fonts folder: {e}"))?;
|
||||||
let present = std::fs::metadata(&path).map(|m| m.len() == sfnt.len() as u64).unwrap_or(false);
|
let present = std::fs::metadata(&path)
|
||||||
|
.map(|m| m.len() == sfnt.len() as u64)
|
||||||
|
.unwrap_or(false);
|
||||||
if !present {
|
if !present {
|
||||||
write_atomic(&path, &sfnt)?;
|
write_atomic(&path, &sfnt)?;
|
||||||
}
|
}
|
||||||
@@ -628,12 +688,21 @@ pub fn import_font_impl(
|
|||||||
fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result<Option<PathBuf>, String> {
|
fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result<Option<PathBuf>, String> {
|
||||||
let font = get_font(conn, id)?.ok_or_else(|| "Font not found".to_string())?;
|
let font = get_font(conn, id)?.ok_or_else(|| "Font not found".to_string())?;
|
||||||
if referenced_by_invoice(conn, &font.sha256)? {
|
if referenced_by_invoice(conn, &font.sha256)? {
|
||||||
conn.execute("UPDATE user_fonts SET hidden = 1 WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
conn.execute(
|
||||||
|
"UPDATE user_fonts SET hidden = 1 WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let shared: i64 = conn
|
let shared: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1", params![font.sha256], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1",
|
||||||
|
params![font.sha256],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if shared > 0 {
|
if shared > 0 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -641,11 +710,19 @@ fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result<Option<PathBuf
|
|||||||
font_path(local_dir, &font.sha256, &font.format).map(Some)
|
font_path(local_dir, &font.sha256, &font.format).map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_user_font_impl(conn: &mut Connection, local_dir: &Path, id: i64) -> Result<RemoveOutcome, String> {
|
pub fn remove_user_font_impl(
|
||||||
|
conn: &mut Connection,
|
||||||
|
local_dir: &Path,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<RemoveOutcome, String> {
|
||||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||||
let file = retire(&tx, local_dir, id)?;
|
let file = retire(&tx, local_dir, id)?;
|
||||||
let still_there: bool = tx
|
let still_there: bool = tx
|
||||||
.query_row("SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)", params![id], |r| r.get::<_, i64>(0))
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)",
|
||||||
|
params![id],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
!= 0;
|
!= 0;
|
||||||
tx.commit().map_err(|e| e.to_string())?;
|
tx.commit().map_err(|e| e.to_string())?;
|
||||||
@@ -653,13 +730,24 @@ pub fn remove_user_font_impl(conn: &mut Connection, local_dir: &Path, id: i64) -
|
|||||||
// Best effort, as in import: the row is already gone.
|
// Best effort, as in import: the row is already gone.
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
Ok(RemoveOutcome { deleted: !still_there, hidden: still_there })
|
Ok(RemoveOutcome {
|
||||||
|
deleted: !still_there,
|
||||||
|
hidden: still_there,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stored sfnt for a font file, hidden or not (an issued invoice must still be able to render with it).
|
/// The stored sfnt for a font file, hidden or not (an issued invoice must still be able to render with it).
|
||||||
pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) -> Result<Vec<u8>, String> {
|
pub fn user_font_bytes_impl(
|
||||||
|
conn: &Connection,
|
||||||
|
local_dir: &Path,
|
||||||
|
sha256: &str,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
let format: Option<String> = conn
|
let format: Option<String> = conn
|
||||||
.query_row("SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1", params![sha256], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1",
|
||||||
|
params![sha256],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let format = format.ok_or_else(|| "This font is not in the font library".to_string())?;
|
let format = format.ok_or_else(|| "This font is not in the font library".to_string())?;
|
||||||
@@ -677,20 +765,43 @@ pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) -
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_import_headers<'a>(headers: &'a Headers, file_name: &'a str) -> Result<ImportRequest<'a>, String> {
|
fn parse_import_headers<'a>(
|
||||||
|
headers: &'a Headers,
|
||||||
|
file_name: &'a str,
|
||||||
|
) -> Result<ImportRequest<'a>, String> {
|
||||||
let face = required_header(headers, "x-face")?;
|
let face = required_header(headers, "x-face")?;
|
||||||
let weight = match headers.get("x-weight").map(|v| v.trim()).filter(|v| !v.is_empty()) {
|
let weight = match headers
|
||||||
|
.get("x-weight")
|
||||||
|
.map(|v| v.trim())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
{
|
||||||
None => None,
|
None => None,
|
||||||
Some(v) => Some(v.parse::<u16>().map_err(|_| "x-weight must be a number".to_string())?),
|
Some(v) => Some(
|
||||||
|
v.parse::<u16>()
|
||||||
|
.map_err(|_| "x-weight must be a number".to_string())?,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let italic = match headers.get("x-style").map(|v| v.trim()).filter(|v| !v.is_empty()) {
|
let italic = match headers
|
||||||
|
.get("x-style")
|
||||||
|
.map(|v| v.trim())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
{
|
||||||
None => None,
|
None => None,
|
||||||
Some("normal") => Some(false),
|
Some("normal") => Some(false),
|
||||||
Some("italic") => Some(true),
|
Some("italic") => Some(true),
|
||||||
Some(_) => return Err("x-style must be normal or italic".into()),
|
Some(_) => return Err("x-style must be normal or italic".into()),
|
||||||
};
|
};
|
||||||
let licence_acknowledged = headers.get("x-licence-ack").map(|v| v.trim() == "true").unwrap_or(false);
|
let licence_acknowledged = headers
|
||||||
Ok(ImportRequest { face, file_name, weight, italic, licence_acknowledged })
|
.get("x-licence-ack")
|
||||||
|
.map(|v| v.trim() == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
Ok(ImportRequest {
|
||||||
|
face,
|
||||||
|
file_name,
|
||||||
|
weight,
|
||||||
|
italic,
|
||||||
|
licence_acknowledged,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// The commands are async so parsing, hashing and file I/O of a multi-MB font stay off the main thread.
|
// The commands are async so parsing, hashing and file I/O of a multi-MB font stay off the main thread.
|
||||||
@@ -701,10 +812,18 @@ pub async fn inspect_font(request: Request<'_>) -> Result<FontInspection, String
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Result<UserFont, String> {
|
pub async fn import_font(
|
||||||
|
request: Request<'_>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<UserFont, String> {
|
||||||
let bytes = raw_body(&request)?;
|
let bytes = raw_body(&request)?;
|
||||||
let headers = header_map(&request);
|
let headers = header_map(&request);
|
||||||
let file_name = decode_header_path(headers.get("x-file-name").map(String::as_str).unwrap_or("font"))?;
|
let file_name = decode_header_path(
|
||||||
|
headers
|
||||||
|
.get("x-file-name")
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("font"),
|
||||||
|
)?;
|
||||||
let face = decode_header_path(required_header(&headers, "x-face")?)?;
|
let face = decode_header_path(required_header(&headers, "x-face")?)?;
|
||||||
let mut headers = headers;
|
let mut headers = headers;
|
||||||
headers.insert("x-face".to_string(), face);
|
headers.insert("x-face".to_string(), face);
|
||||||
@@ -714,19 +833,28 @@ pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_user_fonts(include_hidden: Option<bool>, state: State<'_, AppState>) -> Result<Vec<UserFont>, String> {
|
pub async fn list_user_fonts(
|
||||||
|
include_hidden: Option<bool>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<UserFont>, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
list_user_fonts_impl(&conn, include_hidden.unwrap_or(false))
|
list_user_fonts_impl(&conn, include_hidden.unwrap_or(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_user_font_bytes(sha256: String, state: State<'_, AppState>) -> Result<Response, String> {
|
pub async fn get_user_font_bytes(
|
||||||
|
sha256: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Response, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
user_font_bytes_impl(&conn, &state.local_data_dir, &sha256).map(Response::new)
|
user_font_bytes_impl(&conn, &state.local_data_dir, &sha256).map(Response::new)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_user_font(id: i64, state: State<'_, AppState>) -> Result<RemoveOutcome, String> {
|
pub async fn remove_user_font(
|
||||||
|
id: i64,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<RemoveOutcome, String> {
|
||||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
remove_user_font_impl(&mut conn, &state.local_data_dir, id)
|
remove_user_font_impl(&mut conn, &state.local_data_dir, id)
|
||||||
}
|
}
|
||||||
@@ -740,7 +868,9 @@ mod tests {
|
|||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn jost(name: &str) -> Vec<u8> {
|
fn jost(name: &str) -> Vec<u8> {
|
||||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/jost").join(name);
|
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../public/fonts/jost")
|
||||||
|
.join(name);
|
||||||
std::fs::read(path).unwrap()
|
std::fs::read(path).unwrap()
|
||||||
}
|
}
|
||||||
fn regular() -> Vec<u8> {
|
fn regular() -> Vec<u8> {
|
||||||
@@ -781,7 +911,11 @@ mod tests {
|
|||||||
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
|
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||||
enc.write_all(raw).unwrap();
|
enc.write_all(raw).unwrap();
|
||||||
let z = enc.finish().unwrap();
|
let z = enc.finish().unwrap();
|
||||||
blobs.push(if z.len() < raw.len() { (*tag, z, raw.len()) } else { (*tag, raw.to_vec(), raw.len()) });
|
blobs.push(if z.len() < raw.len() {
|
||||||
|
(*tag, z, raw.len())
|
||||||
|
} else {
|
||||||
|
(*tag, raw.to_vec(), raw.len())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let mut out = vec![0u8; 44 + 20 * n];
|
let mut out = vec![0u8; 44 + 20 * n];
|
||||||
out[0..4].copy_from_slice(b"wOFF");
|
out[0..4].copy_from_slice(b"wOFF");
|
||||||
@@ -803,7 +937,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn request<'a>(face: &'a str, ack: bool) -> ImportRequest<'a> {
|
fn request<'a>(face: &'a str, ack: bool) -> ImportRequest<'a> {
|
||||||
ImportRequest { face, file_name: "C:\\fonts\\Jost-Regular.ttf", weight: None, italic: None, licence_acknowledged: ack }
|
ImportRequest {
|
||||||
|
face,
|
||||||
|
file_name: "C:\\fonts\\Jost-Regular.ttf",
|
||||||
|
weight: None,
|
||||||
|
italic: None,
|
||||||
|
licence_acknowledged: ack,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -821,9 +961,17 @@ mod tests {
|
|||||||
assert_eq!(info.sha256, sha256_hex(®ular()));
|
assert_eq!(info.sha256, sha256_hex(®ular()));
|
||||||
assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings);
|
assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings);
|
||||||
assert!(info.warnings[0].contains("rupee"));
|
assert!(info.warnings[0].contains("rupee"));
|
||||||
let poppins = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/poppins/Poppins-Regular.ttf")).unwrap();
|
let poppins = std::fs::read(
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../public/fonts/poppins/Poppins-Regular.ttf"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let (_, with_rupee) = inspect_bytes(&poppins).unwrap();
|
let (_, with_rupee) = inspect_bytes(&poppins).unwrap();
|
||||||
assert!(with_rupee.has_rupee && with_rupee.warnings.is_empty(), "{:?}", with_rupee.warnings);
|
assert!(
|
||||||
|
with_rupee.has_rupee && with_rupee.warnings.is_empty(),
|
||||||
|
"{:?}",
|
||||||
|
with_rupee.warnings
|
||||||
|
);
|
||||||
assert_eq!(inspect_bytes(&bold()).unwrap().1.weight, 700);
|
assert_eq!(inspect_bytes(&bold()).unwrap().1.weight, 700);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,7 +983,9 @@ mod tests {
|
|||||||
let mut ttc = regular();
|
let mut ttc = regular();
|
||||||
ttc[0..4].copy_from_slice(b"ttcf");
|
ttc[0..4].copy_from_slice(b"ttcf");
|
||||||
assert!(inspect_bytes(&ttc).unwrap_err().contains("collections"));
|
assert!(inspect_bytes(&ttc).unwrap_err().contains("collections"));
|
||||||
assert!(inspect_bytes(b"<!doctype html><html></html>").unwrap_err().contains("not a TrueType"));
|
assert!(inspect_bytes(b"<!doctype html><html></html>")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("not a TrueType"));
|
||||||
assert!(inspect_bytes(b"short").is_err());
|
assert!(inspect_bytes(b"short").is_err());
|
||||||
let mut big = regular();
|
let mut big = regular();
|
||||||
big.resize(MAX_FONT_BYTES + 1, 0);
|
big.resize(MAX_FONT_BYTES + 1, 0);
|
||||||
@@ -856,7 +1006,9 @@ mod tests {
|
|||||||
fn fs_type_rules() {
|
fn fs_type_rules() {
|
||||||
let mut restricted = regular();
|
let mut restricted = regular();
|
||||||
set_fs_type(&mut restricted, FS_RESTRICTED);
|
set_fs_type(&mut restricted, FS_RESTRICTED);
|
||||||
assert!(inspect_bytes(&restricted).unwrap_err().contains("restricted"));
|
assert!(inspect_bytes(&restricted)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("restricted"));
|
||||||
|
|
||||||
// A more permissive bit alongside wins (least restrictive).
|
// A more permissive bit alongside wins (least restrictive).
|
||||||
let mut both = regular();
|
let mut both = regular();
|
||||||
@@ -865,11 +1017,21 @@ mod tests {
|
|||||||
|
|
||||||
let mut preview = regular();
|
let mut preview = regular();
|
||||||
set_fs_type(&mut preview, FS_PREVIEW_PRINT);
|
set_fs_type(&mut preview, FS_PREVIEW_PRINT);
|
||||||
assert!(inspect_bytes(&preview).unwrap().1.warnings.iter().any(|w| w.contains("preview and print")));
|
assert!(inspect_bytes(&preview)
|
||||||
|
.unwrap()
|
||||||
|
.1
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.any(|w| w.contains("preview and print")));
|
||||||
|
|
||||||
let mut no_subset = regular();
|
let mut no_subset = regular();
|
||||||
set_fs_type(&mut no_subset, FS_NO_SUBSETTING);
|
set_fs_type(&mut no_subset, FS_NO_SUBSETTING);
|
||||||
assert!(inspect_bytes(&no_subset).unwrap().1.warnings.iter().any(|w| w.contains("subset")));
|
assert!(inspect_bytes(&no_subset)
|
||||||
|
.unwrap()
|
||||||
|
.1
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.any(|w| w.contains("subset")));
|
||||||
|
|
||||||
let mut bitmap = regular();
|
let mut bitmap = regular();
|
||||||
set_fs_type(&mut bitmap, FS_BITMAP_ONLY);
|
set_fs_type(&mut bitmap, FS_BITMAP_ONLY);
|
||||||
@@ -880,13 +1042,18 @@ mod tests {
|
|||||||
fn refuses_a_font_without_outlines() {
|
fn refuses_a_font_without_outlines() {
|
||||||
let mut font = regular();
|
let mut font = regular();
|
||||||
rename_table(&mut font, b"glyf", b"XXXX");
|
rename_table(&mut font, b"glyf", b"XXXX");
|
||||||
assert!(inspect_bytes(&font).unwrap_err().contains("no glyph outlines"));
|
assert!(inspect_bytes(&font)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("no glyph outlines"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unpacks_woff_to_the_same_font() {
|
fn unpacks_woff_to_the_same_font() {
|
||||||
let woff = to_woff(®ular());
|
let woff = to_woff(®ular());
|
||||||
assert!(woff.len() < regular().len(), "the fixture should actually compress");
|
assert!(
|
||||||
|
woff.len() < regular().len(),
|
||||||
|
"the fixture should actually compress"
|
||||||
|
);
|
||||||
let (sfnt, info) = inspect_bytes(&woff).unwrap();
|
let (sfnt, info) = inspect_bytes(&woff).unwrap();
|
||||||
assert_eq!(info.source_format, "woff");
|
assert_eq!(info.source_format, "woff");
|
||||||
assert_eq!(info.family, "Jost");
|
assert_eq!(info.family, "Jost");
|
||||||
@@ -897,7 +1064,12 @@ mod tests {
|
|||||||
let unpacked = Sfnt::parse(&sfnt).unwrap();
|
let unpacked = Sfnt::parse(&sfnt).unwrap();
|
||||||
assert_eq!(original.tables.len(), unpacked.tables.len());
|
assert_eq!(original.tables.len(), unpacked.tables.len());
|
||||||
for (tag, _, _) in &original.tables {
|
for (tag, _, _) in &original.tables {
|
||||||
assert_eq!(original.table(tag), unpacked.table(tag), "table {}", String::from_utf8_lossy(tag));
|
assert_eq!(
|
||||||
|
original.table(tag),
|
||||||
|
unpacked.table(tag),
|
||||||
|
"table {}",
|
||||||
|
String::from_utf8_lossy(tag)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut damaged = woff.clone();
|
let mut damaged = woff.clone();
|
||||||
let len = damaged.len();
|
let len = damaged.len();
|
||||||
@@ -909,17 +1081,28 @@ mod tests {
|
|||||||
fn import_stores_content_addressed_and_round_trips() {
|
fn import_stores_content_addressed_and_round_trips() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let row =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
assert_eq!(row.face, "Gotham");
|
assert_eq!(row.face, "Gotham");
|
||||||
assert_eq!((row.weight, row.style.as_str(), row.format.as_str()), (400, "normal", "ttf"));
|
assert_eq!(
|
||||||
assert_eq!(row.file_name, "Jost-Regular.ttf", "only the base name is kept");
|
(row.weight, row.style.as_str(), row.format.as_str()),
|
||||||
|
(400, "normal", "ttf")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
row.file_name, "Jost-Regular.ttf",
|
||||||
|
"only the base name is kept"
|
||||||
|
);
|
||||||
assert!(!row.hidden && row.licence_ack_at.len() > 10);
|
assert!(!row.hidden && row.licence_ack_at.len() > 10);
|
||||||
let path = dir.path().join("fonts").join(format!("{}.ttf", row.sha256));
|
let path = dir.path().join("fonts").join(format!("{}.ttf", row.sha256));
|
||||||
assert_eq!(std::fs::read(&path).unwrap(), regular());
|
assert_eq!(std::fs::read(&path).unwrap(), regular());
|
||||||
assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular());
|
assert_eq!(
|
||||||
|
user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(),
|
||||||
|
regular()
|
||||||
|
);
|
||||||
|
|
||||||
// Importing the same file again is a no-op.
|
// Importing the same file again is a no-op.
|
||||||
let again = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let again =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
assert_eq!(again.id, row.id);
|
assert_eq!(again.id, row.id);
|
||||||
assert_eq!(list_user_fonts_impl(&conn, false).unwrap().len(), 1);
|
assert_eq!(list_user_fonts_impl(&conn, false).unwrap().len(), 1);
|
||||||
|
|
||||||
@@ -933,18 +1116,32 @@ mod tests {
|
|||||||
fn import_overrides_and_guards() {
|
fn import_overrides_and_guards() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", false))
|
assert!(
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", false))
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("licence"));
|
.contains("licence")
|
||||||
assert!(import_font_impl(&mut conn, dir.path(), ®ular(), &request("Montserrat", true))
|
);
|
||||||
|
assert!(import_font_impl(
|
||||||
|
&mut conn,
|
||||||
|
dir.path(),
|
||||||
|
®ular(),
|
||||||
|
&request("Montserrat", true)
|
||||||
|
)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("Now, Gotham, Open Sauce One"));
|
.contains("Now, Gotham, Open Sauce One"));
|
||||||
assert!(!dir.path().join("fonts").exists(), "a refused import writes nothing");
|
assert!(
|
||||||
|
!dir.path().join("fonts").exists(),
|
||||||
|
"a refused import writes nothing"
|
||||||
|
);
|
||||||
let mut woff2 = regular();
|
let mut woff2 = regular();
|
||||||
woff2[0..4].copy_from_slice(b"wOF2");
|
woff2[0..4].copy_from_slice(b"wOF2");
|
||||||
assert!(import_font_impl(&mut conn, dir.path(), &woff2, &request("Now", true)).is_err());
|
assert!(import_font_impl(&mut conn, dir.path(), &woff2, &request("Now", true)).is_err());
|
||||||
|
|
||||||
let req = ImportRequest { weight: Some(300), italic: Some(true), ..request("Open Sauce One", true) };
|
let req = ImportRequest {
|
||||||
|
weight: Some(300),
|
||||||
|
italic: Some(true),
|
||||||
|
..request("Open Sauce One", true)
|
||||||
|
};
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &req).unwrap();
|
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &req).unwrap();
|
||||||
assert_eq!((row.weight, row.style.as_str()), (300, "italic"));
|
assert_eq!((row.weight, row.style.as_str()), (300, "italic"));
|
||||||
// The table itself refuses other faces.
|
// The table itself refuses other faces.
|
||||||
@@ -962,15 +1159,23 @@ mod tests {
|
|||||||
fn replacing_a_slot_removes_the_old_unreferenced_font() {
|
fn replacing_a_slot_removes_the_old_unreferenced_font() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let first = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let first =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
// Same slot, different file (the bold file declared as 400).
|
// Same slot, different file (the bold file declared as 400).
|
||||||
let req = ImportRequest { weight: Some(400), ..request("Gotham", true) };
|
let req = ImportRequest {
|
||||||
|
weight: Some(400),
|
||||||
|
..request("Gotham", true)
|
||||||
|
};
|
||||||
let second = import_font_impl(&mut conn, dir.path(), &bold(), &req).unwrap();
|
let second = import_font_impl(&mut conn, dir.path(), &bold(), &req).unwrap();
|
||||||
assert_ne!(first.sha256, second.sha256);
|
assert_ne!(first.sha256, second.sha256);
|
||||||
let rows = list_user_fonts_impl(&conn, true).unwrap();
|
let rows = list_user_fonts_impl(&conn, true).unwrap();
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
assert_eq!(rows[0].sha256, second.sha256);
|
assert_eq!(rows[0].sha256, second.sha256);
|
||||||
assert!(!dir.path().join("fonts").join(format!("{}.ttf", first.sha256)).exists());
|
assert!(!dir
|
||||||
|
.path()
|
||||||
|
.join("fonts")
|
||||||
|
.join(format!("{}.ttf", first.sha256))
|
||||||
|
.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn issue_invoice_with_prefs(conn: &Connection, prefs: &str) {
|
fn issue_invoice_with_prefs(conn: &Connection, prefs: &str) {
|
||||||
@@ -986,11 +1191,22 @@ mod tests {
|
|||||||
fn remove_deletes_when_unreferenced() {
|
fn remove_deletes_when_unreferenced() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
let row =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
||||||
let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap();
|
let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap();
|
||||||
assert_eq!(out, RemoveOutcome { deleted: true, hidden: false });
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
RemoveOutcome {
|
||||||
|
deleted: true,
|
||||||
|
hidden: false
|
||||||
|
}
|
||||||
|
);
|
||||||
assert!(list_user_fonts_impl(&conn, true).unwrap().is_empty());
|
assert!(list_user_fonts_impl(&conn, true).unwrap().is_empty());
|
||||||
assert!(!dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists());
|
assert!(!dir
|
||||||
|
.path()
|
||||||
|
.join("fonts")
|
||||||
|
.join(format!("{}.ttf", row.sha256))
|
||||||
|
.exists());
|
||||||
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).is_err());
|
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).is_err());
|
||||||
assert!(remove_user_font_impl(&mut conn, dir.path(), row.id).is_err());
|
assert!(remove_user_font_impl(&mut conn, dir.path(), row.id).is_err());
|
||||||
}
|
}
|
||||||
@@ -999,20 +1215,42 @@ mod tests {
|
|||||||
fn remove_hides_a_font_an_issued_invoice_references() {
|
fn remove_hides_a_font_an_issued_invoice_references() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let row =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
issue_invoice_with_prefs(
|
issue_invoice_with_prefs(
|
||||||
&conn,
|
&conn,
|
||||||
&format!(r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#, row.sha256),
|
&format!(
|
||||||
|
r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#,
|
||||||
|
row.sha256
|
||||||
|
),
|
||||||
);
|
);
|
||||||
let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap();
|
let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap();
|
||||||
assert_eq!(out, RemoveOutcome { deleted: false, hidden: true });
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
RemoveOutcome {
|
||||||
|
deleted: false,
|
||||||
|
hidden: true
|
||||||
|
}
|
||||||
|
);
|
||||||
// Gone from the visible list, but kept and still served so the invoice can re-render.
|
// Gone from the visible list, but kept and still served so the invoice can re-render.
|
||||||
assert!(list_user_fonts_impl(&conn, false).unwrap().is_empty());
|
assert!(list_user_fonts_impl(&conn, false).unwrap().is_empty());
|
||||||
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 1);
|
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 1);
|
||||||
assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular());
|
assert_eq!(
|
||||||
|
user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(),
|
||||||
|
regular()
|
||||||
|
);
|
||||||
|
|
||||||
// The slot is free again: importing a new font for it works, and the hidden one stays.
|
// The slot is free again: importing a new font for it works, and the hidden one stays.
|
||||||
let fresh = import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap();
|
let fresh = import_font_impl(
|
||||||
|
&mut conn,
|
||||||
|
dir.path(),
|
||||||
|
&bold(),
|
||||||
|
&ImportRequest {
|
||||||
|
weight: Some(400),
|
||||||
|
..request("Gotham", true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_ne!(fresh.sha256, row.sha256);
|
assert_ne!(fresh.sha256, row.sha256);
|
||||||
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 2);
|
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 2);
|
||||||
}
|
}
|
||||||
@@ -1021,10 +1259,27 @@ mod tests {
|
|||||||
fn replacing_a_referenced_font_hides_it_instead_of_deleting() {
|
fn replacing_a_referenced_font_hides_it_instead_of_deleting() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let row =
|
||||||
issue_invoice_with_prefs(&conn, &format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256));
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap();
|
issue_invoice_with_prefs(
|
||||||
assert!(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists());
|
&conn,
|
||||||
|
&format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256),
|
||||||
|
);
|
||||||
|
import_font_impl(
|
||||||
|
&mut conn,
|
||||||
|
dir.path(),
|
||||||
|
&bold(),
|
||||||
|
&ImportRequest {
|
||||||
|
weight: Some(400),
|
||||||
|
..request("Gotham", true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(dir
|
||||||
|
.path()
|
||||||
|
.join("fonts")
|
||||||
|
.join(format!("{}.ttf", row.sha256))
|
||||||
|
.exists());
|
||||||
let all = list_user_fonts_impl(&conn, true).unwrap();
|
let all = list_user_fonts_impl(&conn, true).unwrap();
|
||||||
assert_eq!(all.len(), 2);
|
assert_eq!(all.len(), 2);
|
||||||
assert_eq!(all.iter().filter(|f| f.hidden).count(), 1);
|
assert_eq!(all.iter().filter(|f| f.hidden).count(), 1);
|
||||||
@@ -1034,22 +1289,38 @@ mod tests {
|
|||||||
fn a_file_shared_by_two_faces_survives_removing_one() {
|
fn a_file_shared_by_two_faces_survives_removing_one() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let a = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
let a =
|
||||||
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Gotham", true)).unwrap();
|
||||||
let b = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
let b = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
||||||
assert_eq!(a.sha256, b.sha256);
|
assert_eq!(a.sha256, b.sha256);
|
||||||
remove_user_font_impl(&mut conn, dir.path(), a.id).unwrap();
|
remove_user_font_impl(&mut conn, dir.path(), a.id).unwrap();
|
||||||
assert!(dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists());
|
assert!(dir
|
||||||
|
.path()
|
||||||
|
.join("fonts")
|
||||||
|
.join(format!("{}.ttf", a.sha256))
|
||||||
|
.exists());
|
||||||
remove_user_font_impl(&mut conn, dir.path(), b.id).unwrap();
|
remove_user_font_impl(&mut conn, dir.path(), b.id).unwrap();
|
||||||
assert!(!dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists());
|
assert!(!dir
|
||||||
|
.path()
|
||||||
|
.join("fonts")
|
||||||
|
.join(format!("{}.ttf", a.sha256))
|
||||||
|
.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bytes_are_verified_and_the_hash_shape_checked() {
|
fn bytes_are_verified_and_the_hash_shape_checked() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let row = import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
let row =
|
||||||
std::fs::write(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)), b"tampered").unwrap();
|
import_font_impl(&mut conn, dir.path(), ®ular(), &request("Now", true)).unwrap();
|
||||||
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap_err().contains("corrupted"));
|
std::fs::write(
|
||||||
|
dir.path().join("fonts").join(format!("{}.ttf", row.sha256)),
|
||||||
|
b"tampered",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("corrupted"));
|
||||||
assert!(font_path(dir.path(), "../../etc/passwd", "ttf").is_err());
|
assert!(font_path(dir.path(), "../../etc/passwd", "ttf").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,11 +1332,18 @@ mod tests {
|
|||||||
h.insert("x-weight".into(), "700".into());
|
h.insert("x-weight".into(), "700".into());
|
||||||
h.insert("x-style".into(), "italic".into());
|
h.insert("x-style".into(), "italic".into());
|
||||||
let req = parse_import_headers(&h, "f.ttf").unwrap();
|
let req = parse_import_headers(&h, "f.ttf").unwrap();
|
||||||
assert_eq!((req.weight, req.italic, req.licence_acknowledged), (Some(700), Some(true), true));
|
assert_eq!(
|
||||||
|
(req.weight, req.italic, req.licence_acknowledged),
|
||||||
|
(Some(700), Some(true), true)
|
||||||
|
);
|
||||||
h.insert("x-style".into(), "oblique".into());
|
h.insert("x-style".into(), "oblique".into());
|
||||||
assert!(parse_import_headers(&h, "f.ttf").is_err());
|
assert!(parse_import_headers(&h, "f.ttf").is_err());
|
||||||
h.remove("x-licence-ack");
|
h.remove("x-licence-ack");
|
||||||
h.insert("x-style".into(), "".into());
|
h.insert("x-style".into(), "".into());
|
||||||
assert!(!parse_import_headers(&h, "f.ttf").unwrap().licence_acknowledged);
|
assert!(
|
||||||
|
!parse_import_headers(&h, "f.ttf")
|
||||||
|
.unwrap()
|
||||||
|
.licence_acknowledged
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use super::assets::relative_asset_path;
|
use super::assets::relative_asset_path;
|
||||||
|
use super::payments::{derive_summary, summary_for, today_ist};
|
||||||
use super::series::{validate_series_format, MAX_NUMBER_LEN};
|
use super::series::{validate_series_format, MAX_NUMBER_LEN};
|
||||||
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
||||||
use crate::db::format_number;
|
use crate::db::format_number;
|
||||||
use crate::gst::{self, TaxType};
|
use crate::gst::{self, TaxType};
|
||||||
use super::payments::{derive_summary, summary_for, today_ist};
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
|
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
|
||||||
|
Settings,
|
||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
@@ -140,7 +141,9 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
|
|||||||
match item.mode.as_str() {
|
match item.mode.as_str() {
|
||||||
"rate" => {
|
"rate" => {
|
||||||
if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 {
|
if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 {
|
||||||
return Err(format!("Line {n}: rate and quantity must be positive numbers"));
|
return Err(format!(
|
||||||
|
"Line {n}: rate and quantity must be positive numbers"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if item.quantity > MAX_LINE_RUPEES {
|
if item.quantity > MAX_LINE_RUPEES {
|
||||||
return Err(format!("Line {n}: quantity is too large"));
|
return Err(format!("Line {n}: quantity is too large"));
|
||||||
@@ -271,8 +274,9 @@ pub fn issue_invoice_impl(
|
|||||||
.map_err(db)?
|
.map_err(db)?
|
||||||
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
|
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
|
||||||
|
|
||||||
validate_series_format(&prefix, padding)
|
validate_series_format(&prefix, padding).map_err(|e| {
|
||||||
.map_err(|e| format!("The active invoice series cannot be used: {e}. Start a new series."))?;
|
format!("The active invoice series cannot be used: {e}. Start a new series.")
|
||||||
|
})?;
|
||||||
let number = format_number(&prefix, padding, next_number);
|
let number = format_number(&prefix, padding, next_number);
|
||||||
if number.len() > MAX_NUMBER_LEN {
|
if number.len() > MAX_NUMBER_LEN {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -296,10 +300,16 @@ pub fn issue_invoice_impl(
|
|||||||
};
|
};
|
||||||
let pos = input.place_of_supply_state_code.trim().to_string();
|
let pos = input.place_of_supply_state_code.trim().to_string();
|
||||||
if !pos.is_empty() && !gst::state_exists(&pos) {
|
if !pos.is_empty() && !gst::state_exists(&pos) {
|
||||||
return Err(format!("Place of supply \"{pos}\" is not a valid GST state code"));
|
return Err(format!(
|
||||||
|
"Place of supply \"{pos}\" is not a valid GST state code"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let derived = gst::derive_tax_type(&settings.gst_registration, &settings.vendor_state_code, &pos);
|
let derived = gst::derive_tax_type(
|
||||||
|
&settings.gst_registration,
|
||||||
|
&settings.vendor_state_code,
|
||||||
|
&pos,
|
||||||
|
);
|
||||||
if input.tax_type != derived.as_str() {
|
if input.tax_type != derived.as_str() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}",
|
"Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}",
|
||||||
@@ -309,7 +319,11 @@ pub fn issue_invoice_impl(
|
|||||||
if pos.is_empty() { &settings.vendor_state_code } else { &pos },
|
if pos.is_empty() { &settings.vendor_state_code } else { &pos },
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let tax_rate = if derived == TaxType::None { 0.0 } else { input.tax_rate };
|
let tax_rate = if derived == TaxType::None {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
input.tax_rate
|
||||||
|
};
|
||||||
if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) {
|
if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) {
|
||||||
return Err("Tax rate must be between 0 and 100".into());
|
return Err("Tax rate must be between 0 and 100".into());
|
||||||
}
|
}
|
||||||
@@ -330,9 +344,9 @@ pub fn issue_invoice_impl(
|
|||||||
let snapshot = vendor_snapshot(&settings);
|
let snapshot = vendor_snapshot(&settings);
|
||||||
let bank = bank_snapshot(&tx, input.bank_account_id)?;
|
let bank = bank_snapshot(&tx, input.bank_account_id)?;
|
||||||
let signature_path = match input.signature_path.as_deref().map(str::trim) {
|
let signature_path = match input.signature_path.as_deref().map(str::trim) {
|
||||||
Some(p) if !p.is_empty() => Some(
|
Some(p) if !p.is_empty() => {
|
||||||
relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?,
|
Some(relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?)
|
||||||
),
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?;
|
let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?;
|
||||||
@@ -445,7 +459,10 @@ pub fn issue_invoice_impl(
|
|||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
|
|
||||||
if let Some(draft_id) = input.draft_id {
|
if let Some(draft_id) = input.draft_id {
|
||||||
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id])
|
tx.execute(
|
||||||
|
"DELETE FROM invoice_drafts WHERE id = ?1",
|
||||||
|
params![draft_id],
|
||||||
|
)
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,9 +481,17 @@ pub fn issue_invoice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
||||||
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
|
pub fn cancel_invoice_impl(
|
||||||
|
conn: &mut Connection,
|
||||||
|
id: i64,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<Invoice, String> {
|
||||||
let payments: i64 = conn
|
let payments: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM payments WHERE invoice_id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if payments > 0 {
|
if payments > 0 {
|
||||||
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
|
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
|
||||||
@@ -482,7 +507,11 @@ pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Resu
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if changed == 0 {
|
if changed == 0 {
|
||||||
let status: Option<String> = conn
|
let status: Option<String> = conn
|
||||||
.query_row("SELECT status FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT status FROM invoices WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
return Err(match status {
|
return Err(match status {
|
||||||
@@ -525,7 +554,14 @@ pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, Stri
|
|||||||
let status: String = row.get(6)?;
|
let status: String = row.get(6)?;
|
||||||
let due_date: String = row.get(3)?;
|
let due_date: String = row.get(3)?;
|
||||||
let total_paise = gst::rupees_to_paise(total);
|
let total_paise = gst::rupees_to_paise(total);
|
||||||
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today);
|
let payment = derive_summary(
|
||||||
|
&status,
|
||||||
|
total_paise,
|
||||||
|
row.get(9)?,
|
||||||
|
row.get(10)?,
|
||||||
|
&due_date,
|
||||||
|
&today,
|
||||||
|
);
|
||||||
Ok(InvoiceSummary {
|
Ok(InvoiceSummary {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
number: row.get(1)?,
|
number: row.get(1)?,
|
||||||
@@ -566,7 +602,8 @@ pub struct InvoiceLedgerRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||||
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
|
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
{
|
{
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
||||||
@@ -666,11 +703,17 @@ pub fn save_draft_impl(
|
|||||||
|
|
||||||
pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> {
|
pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC")
|
.prepare(
|
||||||
|
"SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC",
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |r| {
|
.query_map([], |r| {
|
||||||
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?))
|
Ok((
|
||||||
|
r.get::<_, i64>(0)?,
|
||||||
|
r.get::<_, String>(1)?,
|
||||||
|
r.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
@@ -682,7 +725,10 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
|
|||||||
DraftSummary {
|
DraftSummary {
|
||||||
id,
|
id,
|
||||||
updated_at,
|
updated_at,
|
||||||
client_name: json.get("clientName").and_then(|v| v.as_str()).map(String::from),
|
client_name: json
|
||||||
|
.get("clientName")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
total: json.get("total").and_then(|v| v.as_f64()),
|
total: json.get("total").and_then(|v| v.as_f64()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -691,7 +737,11 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
|
|||||||
|
|
||||||
pub fn get_draft_impl(conn: &Connection, id: i64) -> Result<serde_json::Value, String> {
|
pub fn get_draft_impl(conn: &Connection, id: i64) -> Result<serde_json::Value, String> {
|
||||||
let text: String = conn
|
let text: String = conn
|
||||||
.query_row("SELECT payload FROM invoice_drafts WHERE id = ?1", params![id], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT payload FROM invoice_drafts WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.ok_or_else(|| "Draft not found".to_string())?;
|
.ok_or_else(|| "Draft not found".to_string())?;
|
||||||
@@ -765,19 +815,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn next_number(conn: &Connection) -> i64 {
|
fn next_number(conn: &Connection) -> i64 {
|
||||||
conn.query_row("SELECT next_number FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
conn.query_row(
|
||||||
|
"SELECT next_number FROM invoice_series WHERE is_active = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invoice_count(conn: &Connection) -> i64 {
|
fn invoice_count(conn: &Connection) -> i64 {
|
||||||
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0))
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
|
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
||||||
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
|
conn.execute(
|
||||||
|
"UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1",
|
||||||
|
params![first.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
|
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
|
||||||
params![first.id],
|
params![first.id],
|
||||||
@@ -791,7 +850,10 @@ mod tests {
|
|||||||
assert_eq!(row.doc_type, "tax_invoice");
|
assert_eq!(row.doc_type, "tax_invoice");
|
||||||
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
|
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
|
||||||
assert_eq!(row.taxable_paise, 731_000);
|
assert_eq!(row.taxable_paise, 731_000);
|
||||||
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
|
assert_eq!(
|
||||||
|
(row.cgst_paise, row.sgst_paise, row.igst_paise),
|
||||||
|
(65_790, 65_790, 0)
|
||||||
|
);
|
||||||
assert!(!row.reverse_charge);
|
assert!(!row.reverse_charge);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,7 +924,10 @@ mod tests {
|
|||||||
input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})),
|
input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""), "{err}");
|
assert!(
|
||||||
|
err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err();
|
let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err();
|
||||||
assert!(err.contains("does not match"), "{err}");
|
assert!(err.contains("does not match"), "{err}");
|
||||||
assert_eq!(next_number(&conn), 1);
|
assert_eq!(next_number(&conn), 1);
|
||||||
@@ -872,7 +937,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unregistered_supplier_issues_a_plain_invoice_without_tax() {
|
fn unregistered_supplier_issues_a_plain_invoice_without_tax() {
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'", []).unwrap();
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let inv = issue(
|
let inv = issue(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})),
|
input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})),
|
||||||
@@ -929,7 +998,8 @@ mod tests {
|
|||||||
assert!(inv.amount_in_words.contains("Eight Thousand"));
|
assert!(inv.amount_in_words.contains("Eight Thousand"));
|
||||||
|
|
||||||
// Later settings edits do not touch the stored snapshot.
|
// Later settings edits do not touch the stored snapshot.
|
||||||
conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", []).unwrap();
|
conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", [])
|
||||||
|
.unwrap();
|
||||||
let again = fetch_invoice(&conn, inv.id).unwrap();
|
let again = fetch_invoice(&conn, inv.id).unwrap();
|
||||||
assert_eq!(again.vendor_snapshot, inv.vendor_snapshot);
|
assert_eq!(again.vendor_snapshot, inv.vendor_snapshot);
|
||||||
}
|
}
|
||||||
@@ -937,7 +1007,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn bank_snapshot_is_built_from_the_bank_row() {
|
fn bank_snapshot_is_built_from_the_bank_row() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap();
|
let bank_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
|
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
|
||||||
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
|
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
|
||||||
assert_eq!(bank["bankName"], "Test Bank");
|
assert_eq!(bank["bankName"], "Test Bank");
|
||||||
@@ -956,15 +1028,22 @@ mod tests {
|
|||||||
params![format!("INV/{year}-001")],
|
params![format!("INV/{year}-001")],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
conn.execute("INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", [])
|
conn.execute(
|
||||||
|
"INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err();
|
let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err();
|
||||||
assert!(err.contains("already exists"), "{err}");
|
assert!(err.contains("already exists"), "{err}");
|
||||||
assert_eq!(next_number(&conn), 1);
|
assert_eq!(next_number(&conn), 1);
|
||||||
assert_eq!(invoice_count(&conn), 1);
|
assert_eq!(invoice_count(&conn), 1);
|
||||||
// The client insert and the draft delete were rolled back too.
|
// The client insert and the draft delete were rolled back too.
|
||||||
let clients: i64 = conn.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0)).unwrap();
|
let clients: i64 = conn
|
||||||
let drafts: i64 = conn.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0)).unwrap();
|
.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let drafts: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
assert_eq!((clients, drafts), (0, 1));
|
assert_eq!((clients, drafts), (0, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,10 +1058,19 @@ mod tests {
|
|||||||
json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}),
|
json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}),
|
||||||
"greater than zero",
|
"greater than zero",
|
||||||
),
|
),
|
||||||
(json!({"items": [{"mode": "fixed", "amount": -1.0}]}), "zero or more"),
|
(
|
||||||
(json!({"items": [{"mode": "weird", "amount": 1.0}]}), "unknown line type"),
|
json!({"items": [{"mode": "fixed", "amount": -1.0}]}),
|
||||||
|
"zero or more",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"items": [{"mode": "weird", "amount": 1.0}]}),
|
||||||
|
"unknown line type",
|
||||||
|
),
|
||||||
(json!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"),
|
(json!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"),
|
||||||
(json!({"placeOfSupplyStateCode": "99"}), "not a valid GST state code"),
|
(
|
||||||
|
json!({"placeOfSupplyStateCode": "99"}),
|
||||||
|
"not a valid GST state code",
|
||||||
|
),
|
||||||
(json!({"taxRate": 150.0}), "between 0 and 100"),
|
(json!({"taxRate": 150.0}), "between 0 and 100"),
|
||||||
(json!({"signaturePath": "../voiced.db"}), "Signature image"),
|
(json!({"signaturePath": "../voiced.db"}), "Signature image"),
|
||||||
];
|
];
|
||||||
@@ -1001,31 +1089,58 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn supplier_settings_are_validated() {
|
fn supplier_settings_are_validated() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'", []).unwrap();
|
conn.execute(
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("check digit"));
|
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'",
|
||||||
conn.execute("UPDATE app_settings SET vendor_gstin = ''", []).unwrap();
|
[],
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("needs a GSTIN"));
|
)
|
||||||
conn.execute("UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'", []).unwrap();
|
.unwrap();
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code 29"));
|
assert!(issue(&mut conn, input(json!({})))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("check digit"));
|
||||||
|
conn.execute("UPDATE app_settings SET vendor_gstin = ''", [])
|
||||||
|
.unwrap();
|
||||||
|
assert!(issue(&mut conn, input(json!({})))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("needs a GSTIN"));
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(issue(&mut conn, input(json!({})))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("state code 29"));
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'",
|
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("PAN"));
|
assert!(issue(&mut conn, input(json!({})))
|
||||||
conn.execute("UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'", [])
|
.unwrap_err()
|
||||||
|
.contains("PAN"));
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code"));
|
assert!(issue(&mut conn, input(json!({})))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("state code"));
|
||||||
assert_eq!(next_number(&conn), 1);
|
assert_eq!(next_number(&conn), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_series_that_break_the_rules_are_refused() {
|
fn legacy_series_that_break_the_rules_are_refused() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", []).unwrap();
|
conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", [])
|
||||||
|
.unwrap();
|
||||||
let err = issue(&mut conn, input(json!({}))).unwrap_err();
|
let err = issue(&mut conn, input(json!({}))).unwrap_err();
|
||||||
assert!(err.contains("active invoice series"), "{err}");
|
assert!(err.contains("active invoice series"), "{err}");
|
||||||
conn.execute("UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3", []).unwrap();
|
conn.execute(
|
||||||
|
"UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(issue(&mut conn, input(json!({}))).is_err());
|
assert!(issue(&mut conn, input(json!({}))).is_err());
|
||||||
// A counter that outgrows its padding can push the number past 16 characters.
|
// A counter that outgrows its padding can push the number past 16 characters.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1033,7 +1148,9 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("longer than 16"));
|
assert!(issue(&mut conn, input(json!({})))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("longer than 16"));
|
||||||
assert_eq!(invoice_count(&conn), 0);
|
assert_eq!(invoice_count(&conn), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,7 +1178,10 @@ mod tests {
|
|||||||
&json!({"theme": "plain"}),
|
&json!({"theme": "plain"}),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(inv.signature_path.as_deref(), Some("assets/signature-1.png"));
|
assert_eq!(
|
||||||
|
inv.signature_path.as_deref(),
|
||||||
|
Some("assets/signature-1.png")
|
||||||
|
);
|
||||||
let prefs: String = conn
|
let prefs: String = conn
|
||||||
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1082,8 +1202,14 @@ mod tests {
|
|||||||
|
|
||||||
let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err();
|
let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err();
|
||||||
assert!(err.contains("cancelled"), "{err}");
|
assert!(err.contains("cancelled"), "{err}");
|
||||||
assert!(cancel_invoice_impl(&mut conn, 999, "").unwrap_err().contains("not found"));
|
assert!(cancel_invoice_impl(&mut conn, 999, "")
|
||||||
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![inv.id]).unwrap();
|
.unwrap_err()
|
||||||
|
.contains("not found"));
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE invoices SET status = 'draft' WHERE id = ?1",
|
||||||
|
params![inv.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err());
|
assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err());
|
||||||
|
|
||||||
// The number is never reused.
|
// The number is never reused.
|
||||||
@@ -1095,10 +1221,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn drafts_round_trip() {
|
fn drafts_round_trip() {
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
let a = save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap();
|
let a =
|
||||||
|
save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap();
|
||||||
let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap();
|
let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap();
|
||||||
assert_ne!(a, b);
|
assert_ne!(a, b);
|
||||||
let c = save_draft_impl(&conn, Some(a), &json!({"clientName": "Acme 2", "total": 10})).unwrap();
|
let c = save_draft_impl(
|
||||||
|
&conn,
|
||||||
|
Some(a),
|
||||||
|
&json!({"clientName": "Acme 2", "total": 10}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(c, a);
|
assert_eq!(c, a);
|
||||||
assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err());
|
assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err());
|
||||||
|
|
||||||
@@ -1111,7 +1243,8 @@ mod tests {
|
|||||||
let blank = list.iter().find(|d| d.id == b).unwrap();
|
let blank = list.iter().find(|d| d.id == b).unwrap();
|
||||||
assert_eq!((blank.client_name.clone(), blank.total), (None, None));
|
assert_eq!((blank.client_name.clone(), blank.total), (None, None));
|
||||||
|
|
||||||
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a]).unwrap();
|
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a])
|
||||||
|
.unwrap();
|
||||||
assert!(get_draft_impl(&conn, a).is_err());
|
assert!(get_draft_impl(&conn, a).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,22 @@ pub struct LogoAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sha8(bytes: &[u8]) -> String {
|
fn sha8(bytes: &[u8]) -> String {
|
||||||
Sha256::digest(bytes).iter().take(4).map(|b| format!("{b:02x}")).collect()
|
Sha256::digest(bytes)
|
||||||
|
.iter()
|
||||||
|
.take(4)
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive the print and knockout images from `bytes` and store them content-addressed in the asset
|
/// Derive the print and knockout images from `bytes` and store them content-addressed in the asset
|
||||||
/// store. `existing_original` is the already stored file the bytes came from (legacy logos); without
|
/// store. `existing_original` is the already stored file the bytes came from (legacy logos); without
|
||||||
/// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at
|
/// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at
|
||||||
/// older logo files.
|
/// older logo files.
|
||||||
fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option<String>) -> Result<LogoAsset, String> {
|
fn store_logo(
|
||||||
|
data_dir: &Path,
|
||||||
|
bytes: &[u8],
|
||||||
|
existing_original: Option<String>,
|
||||||
|
) -> Result<LogoAsset, String> {
|
||||||
let derived = process_logo(bytes).map_err(|e| e.to_string())?;
|
let derived = process_logo(bytes).map_err(|e| e.to_string())?;
|
||||||
let id = sha8(bytes);
|
let id = sha8(bytes);
|
||||||
let original_path = match existing_original {
|
let original_path = match existing_original {
|
||||||
@@ -38,17 +46,28 @@ fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option<String>)
|
|||||||
write_named_asset(data_dir, &format!("logo-{id}-original.{ext}"), bytes)?
|
write_named_asset(data_dir, &format!("logo-{id}-original.{ext}"), bytes)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let print_path = write_named_asset(data_dir, &format!("logo-{id}-print.png"), &derived.print_png)?;
|
let print_path = write_named_asset(
|
||||||
|
data_dir,
|
||||||
|
&format!("logo-{id}-print.png"),
|
||||||
|
&derived.print_png,
|
||||||
|
)?;
|
||||||
let knockout_path = derived
|
let knockout_path = derived
|
||||||
.knockout_png
|
.knockout_png
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png))
|
.map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png))
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
Ok(LogoAsset { original_path, print_path, knockout_path, metrics: derived.metrics })
|
Ok(LogoAsset {
|
||||||
|
original_path,
|
||||||
|
print_path,
|
||||||
|
knockout_path,
|
||||||
|
metrics: derived.metrics,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn import_logo_from_path(data_dir: &Path, source_path: &str) -> Result<LogoAsset, String> {
|
pub fn import_logo_from_path(data_dir: &Path, source_path: &str) -> Result<LogoAsset, String> {
|
||||||
let len = std::fs::metadata(source_path).map_err(|e| e.to_string())?.len();
|
let len = std::fs::metadata(source_path)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.len();
|
||||||
if len > MAX_INPUT_BYTES as u64 {
|
if len > MAX_INPUT_BYTES as u64 {
|
||||||
return Err(crate::logo::LogoError::TooLarge.to_string());
|
return Err(crate::logo::LogoError::TooLarge.to_string());
|
||||||
}
|
}
|
||||||
@@ -61,7 +80,9 @@ pub fn import_logo_from_base64(data_dir: &Path, data_base64: &str) -> Result<Log
|
|||||||
if data_base64.len() > MAX_INPUT_BYTES / 3 * 4 + 8 {
|
if data_base64.len() > MAX_INPUT_BYTES / 3 * 4 + 8 {
|
||||||
return Err(crate::logo::LogoError::TooLarge.to_string());
|
return Err(crate::logo::LogoError::TooLarge.to_string());
|
||||||
}
|
}
|
||||||
let bytes = STANDARD.decode(data_base64.as_bytes()).map_err(|e| e.to_string())?;
|
let bytes = STANDARD
|
||||||
|
.decode(data_base64.as_bytes())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
store_logo(data_dir, &bytes, None)
|
store_logo(data_dir, &bytes, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +95,11 @@ fn metrics_json(metrics: &LogoMetrics) -> Result<String, String> {
|
|||||||
/// original. Returns whether anything changed.
|
/// original. Returns whether anything changed.
|
||||||
pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bool, String> {
|
pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bool, String> {
|
||||||
let (logo_path, meta): (Option<String>, String) = conn
|
let (logo_path, meta): (Option<String>, String) = conn
|
||||||
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
"SELECT logo_path, logo_meta FROM app_settings WHERE id = 1",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else {
|
let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -86,11 +109,20 @@ pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bo
|
|||||||
}
|
}
|
||||||
let file = resolve_asset(data_dir, &logo_path)?;
|
let file = resolve_asset(data_dir, &logo_path)?;
|
||||||
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
|
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
|
||||||
let asset = store_logo(data_dir, &bytes, Some(relative_asset_path(data_dir, &logo_path)?))?;
|
let asset = store_logo(
|
||||||
|
data_dir,
|
||||||
|
&bytes,
|
||||||
|
Some(relative_asset_path(data_dir, &logo_path)?),
|
||||||
|
)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
|
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
|
||||||
logo_meta = ?4 WHERE id = 1",
|
logo_meta = ?4 WHERE id = 1",
|
||||||
params![asset.print_path, asset.original_path, asset.knockout_path, metrics_json(&asset.metrics)?],
|
params![
|
||||||
|
asset.print_path,
|
||||||
|
asset.original_path,
|
||||||
|
asset.knockout_path,
|
||||||
|
metrics_json(&asset.metrics)?
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -161,7 +193,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
DynamicImage::ImageRgba8(img).write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
|
DynamicImage::ImageRgba8(img)
|
||||||
|
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,16 +214,30 @@ mod tests {
|
|||||||
let bytes = padded_png();
|
let bytes = padded_png();
|
||||||
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap();
|
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap();
|
||||||
let id = sha8(&bytes);
|
let id = sha8(&bytes);
|
||||||
assert_eq!(asset.original_path, format!("assets/logo-{id}-original.png"));
|
assert_eq!(
|
||||||
|
asset.original_path,
|
||||||
|
format!("assets/logo-{id}-original.png")
|
||||||
|
);
|
||||||
assert_eq!(asset.print_path, format!("assets/logo-{id}-print.png"));
|
assert_eq!(asset.print_path, format!("assets/logo-{id}-print.png"));
|
||||||
assert_eq!(asset.knockout_path.as_deref(), Some(format!("assets/logo-{id}-knockout.png").as_str()));
|
assert_eq!(
|
||||||
|
asset.knockout_path.as_deref(),
|
||||||
|
Some(format!("assets/logo-{id}-knockout.png").as_str())
|
||||||
|
);
|
||||||
assert_eq!((asset.metrics.width, asset.metrics.height), (100, 20));
|
assert_eq!((asset.metrics.width, asset.metrics.height), (100, 20));
|
||||||
assert_eq!(std::fs::read(dir.path().join(&asset.original_path)).unwrap(), bytes);
|
assert_eq!(
|
||||||
|
std::fs::read(dir.path().join(&asset.original_path)).unwrap(),
|
||||||
|
bytes
|
||||||
|
);
|
||||||
for rel in [&asset.print_path, asset.knockout_path.as_ref().unwrap()] {
|
for rel in [&asset.print_path, asset.knockout_path.as_ref().unwrap()] {
|
||||||
assert!(resolve_asset(dir.path(), rel).is_ok());
|
assert!(resolve_asset(dir.path(), rel).is_ok());
|
||||||
}
|
}
|
||||||
// The same bytes land on the same names.
|
// The same bytes land on the same names.
|
||||||
assert_eq!(import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap().print_path, asset.print_path);
|
assert_eq!(
|
||||||
|
import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes))
|
||||||
|
.unwrap()
|
||||||
|
.print_path,
|
||||||
|
asset.print_path
|
||||||
|
);
|
||||||
let json = serde_json::to_value(&asset).unwrap();
|
let json = serde_json::to_value(&asset).unwrap();
|
||||||
assert_eq!(json["metrics"]["kind"], "wordmark");
|
assert_eq!(json["metrics"]["kind"], "wordmark");
|
||||||
assert_eq!(json["metrics"]["opaqueBackground"], false);
|
assert_eq!(json["metrics"]["opaqueBackground"], false);
|
||||||
@@ -220,7 +268,8 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
let legacy = write_named_asset(dir.path(), "logo-legacy.png", &padded_png()).unwrap();
|
let legacy = write_named_asset(dir.path(), "logo-legacy.png", &padded_png()).unwrap();
|
||||||
conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy]).unwrap();
|
conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(ensure_logo_derived_impl(&conn, dir.path()).unwrap());
|
assert!(ensure_logo_derived_impl(&conn, dir.path()).unwrap());
|
||||||
let (logo, original, knockout, meta) = logo_columns(&conn);
|
let (logo, original, knockout, meta) = logo_columns(&conn);
|
||||||
@@ -243,7 +292,8 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let conn = crate::db::open_in_memory().unwrap();
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap());
|
assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap());
|
||||||
conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", []).unwrap();
|
conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", [])
|
||||||
|
.unwrap();
|
||||||
assert!(ensure_logo_derived_impl(&conn, dir.path()).is_err());
|
assert!(ensure_logo_derived_impl(&conn, dir.path()).is_err());
|
||||||
// Left untouched so a later start can retry.
|
// Left untouched so a later start can retry.
|
||||||
assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png"));
|
assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png"));
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ pub struct PaymentInput {
|
|||||||
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
|
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
|
||||||
pub fn today_ist() -> String {
|
pub fn today_ist() -> String {
|
||||||
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
|
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
|
||||||
Utc::now().with_timezone(&ist).date_naive().format("%Y-%m-%d").to_string()
|
Utc::now()
|
||||||
|
.with_timezone(&ist)
|
||||||
|
.date_naive()
|
||||||
|
.format("%Y-%m-%d")
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
|
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
|
||||||
@@ -112,7 +116,14 @@ pub fn summary_for(
|
|||||||
params![invoice_id],
|
params![invoice_id],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)?;
|
)?;
|
||||||
Ok(derive_summary(status, rupees_to_paise(total), paid, tds, due_date, &today_ist()))
|
Ok(derive_summary(
|
||||||
|
status,
|
||||||
|
rupees_to_paise(total),
|
||||||
|
paid,
|
||||||
|
tds,
|
||||||
|
due_date,
|
||||||
|
&today_ist(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rupees(paise: i64) -> String {
|
fn rupees(paise: i64) -> String {
|
||||||
@@ -130,7 +141,9 @@ fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
|
|||||||
reference: r.get(6)?,
|
reference: r.get(6)?,
|
||||||
notes: r.get(7)?,
|
notes: r.get(7)?,
|
||||||
created_at: r.get(8)?,
|
created_at: r.get(8)?,
|
||||||
erpnext_payment_entry: r.get::<_, Option<String>>(9)?.filter(|e| !e.trim().is_empty()),
|
erpnext_payment_entry: r
|
||||||
|
.get::<_, Option<String>>(9)?
|
||||||
|
.filter(|e| !e.trim().is_empty()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +178,9 @@ pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result
|
|||||||
.map_err(db)?
|
.map_err(db)?
|
||||||
.ok_or_else(|| "Invoice not found".to_string())?;
|
.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
if status != "issued" {
|
if status != "issued" {
|
||||||
return Err(format!("Payments can only be recorded on an issued invoice (this one is {status})"));
|
return Err(format!(
|
||||||
|
"Payments can only be recorded on an issued invoice (this one is {status})"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let total_paise = rupees_to_paise(total);
|
let total_paise = rupees_to_paise(total);
|
||||||
let settled: i64 = tx
|
let settled: i64 = tx
|
||||||
@@ -203,7 +218,11 @@ pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result
|
|||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
let id = tx.last_insert_rowid();
|
let id = tx.last_insert_rowid();
|
||||||
let payment = tx
|
let payment = tx
|
||||||
.query_row(&format!("SELECT {COLS} FROM payments WHERE id = ?1"), params![id], map_payment)
|
.query_row(
|
||||||
|
&format!("SELECT {COLS} FROM payments WHERE id = ?1"),
|
||||||
|
params![id],
|
||||||
|
map_payment,
|
||||||
|
)
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
tx.commit().map_err(db)?;
|
tx.commit().map_err(db)?;
|
||||||
Ok(payment)
|
Ok(payment)
|
||||||
@@ -270,7 +289,9 @@ mod tests {
|
|||||||
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
|
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})).unwrap().id
|
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({}))
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
|
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
|
||||||
@@ -287,9 +308,11 @@ mod tests {
|
|||||||
|
|
||||||
fn summary(conn: &Connection, id: i64) -> PaymentSummary {
|
fn summary(conn: &Connection, id: i64) -> PaymentSummary {
|
||||||
let (status, total, due): (String, f64, String) = conn
|
let (status, total, due): (String, f64, String) = conn
|
||||||
.query_row("SELECT status, total, due_date FROM invoices WHERE id = ?1", params![id], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
"SELECT status, total, due_date FROM invoices WHERE id = ?1",
|
||||||
})
|
params![id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
summary_for(conn, id, &status, total, &due).unwrap()
|
summary_for(conn, id, &status, total, &due).unwrap()
|
||||||
}
|
}
|
||||||
@@ -303,7 +326,10 @@ mod tests {
|
|||||||
|
|
||||||
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
|
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
|
||||||
let s = summary(&conn, id);
|
let s = summary(&conn, id);
|
||||||
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.balance_paise), ("partially_paid", 40_000, 60_000));
|
assert_eq!(
|
||||||
|
(s.payment_status.as_str(), s.paid_paise, s.balance_paise),
|
||||||
|
("partially_paid", 40_000, 60_000)
|
||||||
|
);
|
||||||
|
|
||||||
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
|
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
|
||||||
let s = summary(&conn, id);
|
let s = summary(&conn, id);
|
||||||
@@ -317,7 +343,15 @@ mod tests {
|
|||||||
// 10% TDS: the client pays 900.00 in cash and deducts 100.00.
|
// 10% TDS: the client pays 900.00 in cash and deducts 100.00.
|
||||||
record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap();
|
record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap();
|
||||||
let s = summary(&conn, id);
|
let s = summary(&conn, id);
|
||||||
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.tds_paise, s.balance_paise), ("paid", 90_000, 10_000, 0));
|
assert_eq!(
|
||||||
|
(
|
||||||
|
s.payment_status.as_str(),
|
||||||
|
s.paid_paise,
|
||||||
|
s.tds_paise,
|
||||||
|
s.balance_paise
|
||||||
|
),
|
||||||
|
("paid", 90_000, 10_000, 0)
|
||||||
|
);
|
||||||
// TDS alone is a valid payment too.
|
// TDS alone is a valid payment too.
|
||||||
let id2 = issued(&mut conn, 500.0, "2999-01-01");
|
let id2 = issued(&mut conn, 500.0, "2999-01-01");
|
||||||
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
|
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
|
||||||
@@ -329,13 +363,18 @@ mod tests {
|
|||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
|
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
|
||||||
assert!(err.contains("more than the outstanding balance of ₹1000.00"), "{err}");
|
assert!(
|
||||||
|
err.contains("more than the outstanding balance of ₹1000.00"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
// Cash plus TDS is what counts.
|
// Cash plus TDS is what counts.
|
||||||
assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err());
|
assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err());
|
||||||
record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap();
|
record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap();
|
||||||
let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err();
|
let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err();
|
||||||
assert!(err.contains("₹500.00"), "{err}");
|
assert!(err.contains("₹500.00"), "{err}");
|
||||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0)).unwrap();
|
let n: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(n, 1);
|
assert_eq!(n, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +413,11 @@ mod tests {
|
|||||||
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
|
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
|
||||||
|
|
||||||
let id2 = issued(&mut conn, 1000.0, "2999-01-01");
|
let id2 = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![id2]).unwrap();
|
conn.execute(
|
||||||
|
"UPDATE invoices SET status = 'draft' WHERE id = ?1",
|
||||||
|
params![id2],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
|
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,27 +440,38 @@ mod tests {
|
|||||||
assert_eq!(summary(&conn, id).payment_status, "paid");
|
assert_eq!(summary(&conn, id).payment_status, "paid");
|
||||||
delete_payment_impl(&conn, full.id).unwrap();
|
delete_payment_impl(&conn, full.id).unwrap();
|
||||||
let s = summary(&conn, id);
|
let s = summary(&conn, id);
|
||||||
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("unpaid", 100_000));
|
assert_eq!(
|
||||||
|
(s.payment_status.as_str(), s.balance_paise),
|
||||||
|
("unpaid", 100_000)
|
||||||
|
);
|
||||||
assert!(list_payments_impl(&conn, id).unwrap().is_empty());
|
assert!(list_payments_impl(&conn, id).unwrap().is_empty());
|
||||||
assert!(delete_payment_impl(&conn, full.id).is_err());
|
assert!(delete_payment_impl(&conn, full.id).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn overdue_is_derived_from_due_date_and_balance() {
|
fn overdue_is_derived_from_due_date_and_balance() {
|
||||||
let d = |due: &str, paid: i64, tds: i64| derive_summary("issued", 100_000, paid, tds, due, "2026-05-10");
|
let d = |due: &str, paid: i64, tds: i64| {
|
||||||
|
derive_summary("issued", 100_000, paid, tds, due, "2026-05-10")
|
||||||
|
};
|
||||||
// Due today is not overdue yet; the day after is.
|
// Due today is not overdue yet; the day after is.
|
||||||
assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid");
|
assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid");
|
||||||
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
|
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
|
||||||
// Overdue wins over partially paid; the paid amount is still reported.
|
// Overdue wins over partially paid; the paid amount is still reported.
|
||||||
let s = d("2026-05-09", 10_000, 0);
|
let s = d("2026-05-09", 10_000, 0);
|
||||||
assert_eq!((s.payment_status.as_str(), s.paid_paise), ("overdue", 10_000));
|
assert_eq!(
|
||||||
|
(s.payment_status.as_str(), s.paid_paise),
|
||||||
|
("overdue", 10_000)
|
||||||
|
);
|
||||||
// A settled invoice is never overdue.
|
// A settled invoice is never overdue.
|
||||||
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
|
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
|
||||||
// No usable due date means never overdue.
|
// No usable due date means never overdue.
|
||||||
assert_eq!(d("", 0, 0).payment_status, "unpaid");
|
assert_eq!(d("", 0, 0).payment_status, "unpaid");
|
||||||
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
|
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
|
||||||
// Cancelled invoices are not owed.
|
// Cancelled invoices are not owed.
|
||||||
assert_eq!(derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise, 0);
|
assert_eq!(
|
||||||
|
derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise,
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -429,7 +483,10 @@ mod tests {
|
|||||||
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
||||||
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
||||||
let s = summary(&conn, id);
|
let s = summary(&conn, id);
|
||||||
assert_eq!((s.paid_paise, s.balance_paise, s.payment_status.as_str()), (9999, 0, "paid"));
|
assert_eq!(
|
||||||
|
(s.paid_paise, s.balance_paise, s.payment_status.as_str()),
|
||||||
|
(9999, 0, "paid")
|
||||||
|
);
|
||||||
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
|
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
|
||||||
assert_eq!(inv.total_paise, 9999);
|
assert_eq!(inv.total_paise, 9999);
|
||||||
}
|
}
|
||||||
@@ -445,7 +502,14 @@ mod tests {
|
|||||||
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
|
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
|
||||||
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
|
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
|
||||||
let ra = get(a);
|
let ra = get(a);
|
||||||
assert_eq!((ra.payment.paid_paise, ra.payment.tds_paise, ra.payment.balance_paise), (40_000, 5_000, 55_000));
|
assert_eq!(
|
||||||
|
(
|
||||||
|
ra.payment.paid_paise,
|
||||||
|
ra.payment.tds_paise,
|
||||||
|
ra.payment.balance_paise
|
||||||
|
),
|
||||||
|
(40_000, 5_000, 55_000)
|
||||||
|
);
|
||||||
assert_eq!(ra.payment.payment_status, "overdue");
|
assert_eq!(ra.payment.payment_status, "overdue");
|
||||||
assert_eq!(ra.total_paise, 100_000);
|
assert_eq!(ra.total_paise, 100_000);
|
||||||
assert_eq!(get(b).payment.payment_status, "paid");
|
assert_eq!(get(b).payment.payment_status, "paid");
|
||||||
|
|||||||
@@ -136,7 +136,10 @@ mod tests {
|
|||||||
let saved = save_preset_impl(&conn, p).unwrap();
|
let saved = save_preset_impl(&conn, p).unwrap();
|
||||||
assert_eq!(saved.description, "Voice-over session");
|
assert_eq!(saved.description, "Voice-over session");
|
||||||
assert_eq!(saved.hsn_sac, "998397");
|
assert_eq!(saved.hsn_sac, "998397");
|
||||||
assert_eq!((saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise), ("rate", "hour", 150_050));
|
assert_eq!(
|
||||||
|
(saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise),
|
||||||
|
("rate", "hour", 150_050)
|
||||||
|
);
|
||||||
|
|
||||||
let mut edit = saved.clone();
|
let mut edit = saved.clone();
|
||||||
edit.rate_paise = 200_000;
|
edit.rate_paise = 200_000;
|
||||||
|
|||||||
@@ -154,7 +154,11 @@ mod tests {
|
|||||||
assert_eq!(s.prefix, "FY27/");
|
assert_eq!(s.prefix, "FY27/");
|
||||||
assert_eq!(s.next_invoice_number, "FY27/0001");
|
assert_eq!(s.next_invoice_number, "FY27/0001");
|
||||||
let active: i64 = c
|
let active: i64 = c
|
||||||
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM invoice_series WHERE is_active = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(active, 1);
|
assert_eq!(active, 1);
|
||||||
}
|
}
|
||||||
@@ -163,12 +167,20 @@ mod tests {
|
|||||||
fn invalid_series_leaves_the_active_one_alone() {
|
fn invalid_series_leaves_the_active_one_alone() {
|
||||||
let mut c = conn();
|
let mut c = conn();
|
||||||
let before: i64 = c
|
let before: i64 = c
|
||||||
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT id FROM invoice_series WHERE is_active = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err());
|
assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err());
|
||||||
assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err());
|
assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err());
|
||||||
let after: i64 = c
|
let after: i64 = c
|
||||||
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT id FROM invoice_series WHERE is_active = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(before, after);
|
assert_eq!(before, after);
|
||||||
}
|
}
|
||||||
@@ -184,7 +196,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
|
assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
|
||||||
let active: i64 = c
|
let active: i64 = c
|
||||||
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM invoice_series WHERE is_active = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(active, 1);
|
assert_eq!(active, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,8 +142,14 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
|
|||||||
r#"UPDATE bank_accounts SET label=?1, bank_name=?2, account_name=?3,
|
r#"UPDATE bank_accounts SET label=?1, bank_name=?2, account_name=?3,
|
||||||
account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#,
|
account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#,
|
||||||
params![
|
params![
|
||||||
bank.label, bank.bank_name, bank.account_name, bank.account_no,
|
bank.label,
|
||||||
bank.branch, bank.ifsc, is_default, id
|
bank.bank_name,
|
||||||
|
bank.account_name,
|
||||||
|
bank.account_no,
|
||||||
|
bank.branch,
|
||||||
|
bank.ifsc,
|
||||||
|
is_default,
|
||||||
|
id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -155,8 +161,13 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
|
|||||||
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
|
||||||
params![
|
params![
|
||||||
bank.label, bank.bank_name, bank.account_name, bank.account_no,
|
bank.label,
|
||||||
bank.branch, bank.ifsc, is_default
|
bank.bank_name,
|
||||||
|
bank.account_name,
|
||||||
|
bank.account_no,
|
||||||
|
bank.branch,
|
||||||
|
bank.ifsc,
|
||||||
|
is_default
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -208,10 +219,16 @@ pub fn delete_bank_impl(conn: &mut Connection, id: i64) -> Result<(), String> {
|
|||||||
tx.execute("UPDATE bank_accounts SET is_default = 0", [])
|
tx.execute("UPDATE bank_accounts SET is_default = 0", [])
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if let Some(next) = next {
|
if let Some(next) = next {
|
||||||
tx.execute("UPDATE bank_accounts SET is_default = 1 WHERE id = ?1", params![next])
|
tx.execute(
|
||||||
|
"UPDATE bank_accounts SET is_default = 1 WHERE id = ?1",
|
||||||
|
params![next],
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
tx.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![next])
|
tx.execute(
|
||||||
|
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
||||||
|
params![next],
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
tx.commit().map_err(|e| e.to_string())
|
tx.commit().map_err(|e| e.to_string())
|
||||||
@@ -238,12 +255,20 @@ mod tests {
|
|||||||
|
|
||||||
fn default_state(conn: &Connection) -> (Option<i64>, Vec<i64>) {
|
fn default_state(conn: &Connection) -> (Option<i64>, Vec<i64>) {
|
||||||
let setting = conn
|
let setting = conn
|
||||||
.query_row("SELECT default_bank_id FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT default_bank_id FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id")
|
.prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let flagged = stmt.query_map([], |r| r.get(0)).unwrap().map(Result::unwrap).collect();
|
let flagged = stmt
|
||||||
|
.query_map([], |r| r.get(0))
|
||||||
|
.unwrap()
|
||||||
|
.map(Result::unwrap)
|
||||||
|
.collect();
|
||||||
(setting, flagged)
|
(setting, flagged)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +284,10 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
assert_eq!(read(&conn).render_prefs, "");
|
assert_eq!(read(&conn).render_prefs, "");
|
||||||
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"])
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET render_prefs = ?1 WHERE id = 1",
|
||||||
|
params!["{\"version\":1}"],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let settings = read(&conn);
|
let settings = read(&conn);
|
||||||
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
||||||
|
|||||||
+189
-50
@@ -482,7 +482,10 @@ pub(crate) fn seed_test_fixture(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
let bank_id = conn.last_insert_rowid();
|
let bank_id = conn.last_insert_rowid();
|
||||||
conn.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![bank_id])?;
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
||||||
|
params![bank_id],
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,8 +545,9 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R
|
|||||||
}
|
}
|
||||||
|
|
||||||
let snapshots: Vec<(i64, String)> = {
|
let snapshots: Vec<(i64, String)> = {
|
||||||
let mut stmt =
|
let mut stmt = tx.prepare(
|
||||||
tx.prepare("SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL")?;
|
"SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL",
|
||||||
|
)?;
|
||||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
rows.collect::<rusqlite::Result<_>>()?
|
rows.collect::<rusqlite::Result<_>>()?
|
||||||
};
|
};
|
||||||
@@ -574,7 +578,12 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R
|
|||||||
|
|
||||||
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
|
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
|
||||||
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
||||||
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
|
format!(
|
||||||
|
"{}{:0width$}",
|
||||||
|
prefix,
|
||||||
|
number,
|
||||||
|
width = padding.max(1) as usize
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -682,7 +691,8 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
"#;
|
"#;
|
||||||
|
|
||||||
fn user_version(conn: &Connection) -> i64 {
|
fn user_version(conn: &Connection) -> i64 {
|
||||||
conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap()
|
conn.pragma_query_value(None, "user_version", |r| r.get(0))
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backups_in(dir: &Path) -> Vec<PathBuf> {
|
fn backups_in(dir: &Path) -> Vec<PathBuf> {
|
||||||
@@ -724,7 +734,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
fn m4_adds_archive_and_export_dir_columns() {
|
fn m4_adds_archive_and_export_dir_columns() {
|
||||||
let conn = open_in_memory().unwrap();
|
let conn = open_in_memory().unwrap();
|
||||||
let dir: String = conn
|
let dir: String = conn
|
||||||
.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT last_export_dir FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(dir, "");
|
assert_eq!(dir, "");
|
||||||
// The columns exist and are nullable on invoices (no rows needed to prove it).
|
// The columns exist and are nullable on invoices (no rows needed to prove it).
|
||||||
@@ -745,14 +759,26 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
fn render_prefs_column_defaults_to_empty_and_round_trips() {
|
fn render_prefs_column_defaults_to_empty_and_round_trips() {
|
||||||
let conn = open_in_memory().unwrap();
|
let conn = open_in_memory().unwrap();
|
||||||
let initial: String = conn
|
let initial: String = conn
|
||||||
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT render_prefs FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(initial, "");
|
assert_eq!(initial, "");
|
||||||
|
|
||||||
let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#;
|
let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#;
|
||||||
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", [json]).unwrap();
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET render_prefs = ?1 WHERE id = 1",
|
||||||
|
[json],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let stored: String = conn
|
let stored: String = conn
|
||||||
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT render_prefs FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(stored, json);
|
assert_eq!(stored, json);
|
||||||
}
|
}
|
||||||
@@ -779,15 +805,21 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
.to_latest(&mut old)
|
.to_latest(&mut old)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
seed(&old).unwrap();
|
seed(&old).unwrap();
|
||||||
old.execute("UPDATE app_settings SET logo_path = 'assets/logo-old.png'", []).unwrap();
|
old.execute(
|
||||||
|
"UPDATE app_settings SET logo_path = 'assets/logo-old.png'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(user_version(&old), 4);
|
assert_eq!(user_version(&old), 4);
|
||||||
}
|
}
|
||||||
let conn = open(&path, &dir.path().join("backups")).unwrap();
|
let conn = open(&path, &dir.path().join("backups")).unwrap();
|
||||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
let (logo, meta): (String, String) = conn
|
let (logo, meta): (String, String) = conn
|
||||||
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
"SELECT logo_path, logo_meta FROM app_settings WHERE id = 1",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", ""));
|
assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", ""));
|
||||||
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
|
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
|
||||||
@@ -808,7 +840,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let (cat, terms): (String, Option<i64>) = conn
|
let (cat, terms): (String, Option<i64>) = conn
|
||||||
.query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?)))
|
.query_row(
|
||||||
|
"SELECT gst_category, payment_terms_days FROM clients",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((cat.as_str(), terms), ("unregistered", None));
|
assert_eq!((cat.as_str(), terms), ("unregistered", None));
|
||||||
// The category CHECK rejects unknown values.
|
// The category CHECK rejects unknown values.
|
||||||
@@ -820,7 +856,14 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
#[test]
|
#[test]
|
||||||
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
|
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
|
||||||
let mut conn = Connection::open_in_memory().unwrap();
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)];
|
let all = vec![
|
||||||
|
M::up(SCHEMA),
|
||||||
|
M::up(M2),
|
||||||
|
M::up(M3),
|
||||||
|
M::up(M4),
|
||||||
|
M::up(M5),
|
||||||
|
M::up(M6),
|
||||||
|
];
|
||||||
Migrations::new(all).to_latest(&mut conn).unwrap();
|
Migrations::new(all).to_latest(&mut conn).unwrap();
|
||||||
assert_eq!(user_version(&conn), 6);
|
assert_eq!(user_version(&conn), 6);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -846,7 +889,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
.collect::<Result<_, _>>()
|
.collect::<Result<_, _>>()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rows[0].1, "Line one\nLine two");
|
assert_eq!(rows[0].1, "Line one\nLine two");
|
||||||
assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1"));
|
assert_eq!(
|
||||||
|
(rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()),
|
||||||
|
("29ABCDE1234F1Z5", "29", "PO-1")
|
||||||
|
);
|
||||||
assert_eq!(rows[0].5, "registered_regular");
|
assert_eq!(rows[0].5, "registered_regular");
|
||||||
assert_eq!(rows[0].6, "");
|
assert_eq!(rows[0].6, "");
|
||||||
assert_eq!(rows[1].5, "unregistered");
|
assert_eq!(rows[1].5, "unregistered");
|
||||||
@@ -864,25 +910,43 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((naming.as_str(), submit, attach, auto_push, create_customers), ("mirror", 0, 1, 0, 1));
|
assert_eq!(
|
||||||
|
(naming.as_str(), submit, attach, auto_push, create_customers),
|
||||||
|
("mirror", 0, 1, 0, 1)
|
||||||
|
);
|
||||||
assert_eq!(secret, "");
|
assert_eq!(secret, "");
|
||||||
assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok());
|
assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok());
|
||||||
let rows: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0)).unwrap();
|
let rows: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(rows, 1);
|
assert_eq!(rows, 1);
|
||||||
// Single row only.
|
// Single row only.
|
||||||
assert!(conn.execute("INSERT INTO erpnext_config (id) VALUES (2)", []).is_err());
|
assert!(conn
|
||||||
|
.execute("INSERT INTO erpnext_config (id) VALUES (2)", [])
|
||||||
|
.is_err());
|
||||||
conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync")
|
conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients").unwrap();
|
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients")
|
||||||
conn.prepare("SELECT erpnext_payment_entry FROM payments").unwrap();
|
.unwrap();
|
||||||
conn.prepare("SELECT erpnext_item_code FROM item_presets").unwrap();
|
conn.prepare("SELECT erpnext_payment_entry FROM payments")
|
||||||
|
.unwrap();
|
||||||
|
conn.prepare("SELECT erpnext_item_code FROM item_presets")
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
|
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
|
||||||
let mut conn = Connection::open_in_memory().unwrap();
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
let v9 = vec![
|
let v9 = vec![
|
||||||
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9),
|
M::up(SCHEMA),
|
||||||
|
M::up(M2),
|
||||||
|
M::up(M3),
|
||||||
|
M::up(M4),
|
||||||
|
M::up(M5),
|
||||||
|
M::up(M6),
|
||||||
|
M::up(M7),
|
||||||
|
M::up(M8),
|
||||||
|
M::up(M9),
|
||||||
];
|
];
|
||||||
Migrations::new(v9).to_latest(&mut conn).unwrap();
|
Migrations::new(v9).to_latest(&mut conn).unwrap();
|
||||||
assert_eq!(user_version(&conn), 9);
|
assert_eq!(user_version(&conn), 9);
|
||||||
@@ -902,37 +966,70 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
migrations().to_latest(&mut conn).unwrap();
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
|
|
||||||
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) = conn
|
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) =
|
||||||
.query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| {
|
conn.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
"SELECT name, gstin, erpnext_customer, erpnext_address FROM clients",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((name.as_str(), gstin.as_str(), customer, address), ("Acme", "29ABCDE1234F1Z5", None, None));
|
assert_eq!(
|
||||||
|
(name.as_str(), gstin.as_str(), customer, address),
|
||||||
|
("Acme", "29ABCDE1234F1Z5", None, None)
|
||||||
|
);
|
||||||
let (desc, rate, code): (String, i64, Option<String>) = conn
|
let (desc, rate, code): (String, i64, Option<String>) = conn
|
||||||
.query_row("SELECT description, rate_paise, erpnext_item_code FROM item_presets", [], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
"SELECT description, rate_paise, erpnext_item_code FROM item_presets",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
|
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
|
||||||
let (paid, entry): (i64, Option<String>) = conn
|
let (paid, entry): (i64, Option<String>) = conn
|
||||||
.query_row("SELECT amount_paise, erpnext_payment_entry FROM payments", [], |r| Ok((r.get(0)?, r.get(1)?)))
|
.query_row(
|
||||||
|
"SELECT amount_paise, erpnext_payment_entry FROM payments",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((paid, entry), (12345, None));
|
assert_eq!((paid, entry), (12345, None));
|
||||||
let number: String = conn.query_row("SELECT number FROM invoices", [], |r| r.get(0)).unwrap();
|
let number: String = conn
|
||||||
|
.query_row("SELECT number FROM invoices", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(number, "INV/2026-001");
|
assert_eq!(number, "INV/2026-001");
|
||||||
|
|
||||||
// The config row exists with defaults, and a sync row can reference the invoice.
|
// The config row exists with defaults, and a sync row can reference the invoice.
|
||||||
let enabled: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config WHERE id = 1", [], |r| r.get(0)).unwrap();
|
let enabled: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM erpnext_config WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(enabled, 1);
|
assert_eq!(enabled, 1);
|
||||||
conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap();
|
conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap();
|
||||||
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", []).is_err());
|
assert!(conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')",
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
|
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
|
||||||
let mut conn = Connection::open_in_memory().unwrap();
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
let v10 = vec![
|
let v10 = vec![
|
||||||
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), M::up(M10),
|
M::up(SCHEMA),
|
||||||
|
M::up(M2),
|
||||||
|
M::up(M3),
|
||||||
|
M::up(M4),
|
||||||
|
M::up(M5),
|
||||||
|
M::up(M6),
|
||||||
|
M::up(M7),
|
||||||
|
M::up(M8),
|
||||||
|
M::up(M9),
|
||||||
|
M::up(M10),
|
||||||
];
|
];
|
||||||
Migrations::new(v10).to_latest(&mut conn).unwrap();
|
Migrations::new(v10).to_latest(&mut conn).unwrap();
|
||||||
assert_eq!(user_version(&conn), 10);
|
assert_eq!(user_version(&conn), 10);
|
||||||
@@ -947,7 +1044,9 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err());
|
assert!(conn
|
||||||
|
.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
|
||||||
|
.is_err());
|
||||||
|
|
||||||
migrations().to_latest(&mut conn).unwrap();
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
assert_eq!(user_version(&conn), 11);
|
assert_eq!(user_version(&conn), 11);
|
||||||
@@ -960,12 +1059,27 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
row,
|
row,
|
||||||
("INV/2026-001".into(), 1, "synced".into(), "abc".into(), String::new(), Some("2026-04-02T00:00:00Z".into()))
|
(
|
||||||
|
"INV/2026-001".into(),
|
||||||
|
1,
|
||||||
|
"synced".into(),
|
||||||
|
"abc".into(),
|
||||||
|
String::new(),
|
||||||
|
Some("2026-04-02T00:00:00Z".into())
|
||||||
|
)
|
||||||
);
|
);
|
||||||
conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).unwrap();
|
conn.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
|
||||||
assert!(conn.execute("UPDATE erpnext_sync SET status = 'bogus'", []).is_err());
|
.unwrap();
|
||||||
|
assert!(conn
|
||||||
|
.execute("UPDATE erpnext_sync SET status = 'bogus'", [])
|
||||||
|
.is_err());
|
||||||
// The unique key still guards one row per invoice.
|
// The unique key still guards one row per invoice.
|
||||||
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", []).is_err());
|
assert!(conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')",
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_v1_database(path: &Path) {
|
fn build_v1_database(path: &Path) {
|
||||||
@@ -1018,7 +1132,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
let files = backups_in(&backups);
|
let files = backups_in(&backups);
|
||||||
assert_eq!(files.len(), 1);
|
assert_eq!(files.len(), 1);
|
||||||
let name = files[0].file_name().unwrap().to_str().unwrap();
|
let name = files[0].file_name().unwrap().to_str().unwrap();
|
||||||
assert!(name.starts_with("voiced-pre-v0-") && name.ends_with(".db"), "{name}");
|
assert!(
|
||||||
|
name.starts_with("voiced-pre-v0-") && name.ends_with(".db"),
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
let backup = Connection::open(&files[0]).unwrap();
|
let backup = Connection::open(&files[0]).unwrap();
|
||||||
assert_eq!(user_version(&backup), 0);
|
assert_eq!(user_version(&backup), 0);
|
||||||
let n: i64 = backup
|
let n: i64 = backup
|
||||||
@@ -1039,7 +1156,13 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
assert_eq!(vendor, "Acme Studio");
|
assert_eq!(vendor, "Acme Studio");
|
||||||
|
|
||||||
// Invoice backfill.
|
// Invoice backfill.
|
||||||
let (origin, snapshot, doc_type, number, total): (String, Option<String>, String, String, f64) = conn
|
let (origin, snapshot, doc_type, number, total): (
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
f64,
|
||||||
|
) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices",
|
"SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices",
|
||||||
[],
|
[],
|
||||||
@@ -1107,7 +1230,9 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
.collect();
|
.collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
assert_eq!(names.len(), MAX_BACKUPS);
|
assert_eq!(names.len(), MAX_BACKUPS);
|
||||||
assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104")));
|
assert!(names
|
||||||
|
.iter()
|
||||||
|
.all(|n| !n.contains("20260101") && !n.contains("20260104")));
|
||||||
assert!(dir.path().join("unrelated.db").exists());
|
assert!(dir.path().join("unrelated.db").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,8 +1241,16 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let data = dir.path();
|
let data = dir.path();
|
||||||
let conn = open(&data.join("voiced.db"), &data.join("backups")).unwrap();
|
let conn = open(&data.join("voiced.db"), &data.join("backups")).unwrap();
|
||||||
let abs_logo = data.join("assets").join("logo-1.png").to_string_lossy().into_owned();
|
let abs_logo = data
|
||||||
let abs_sig = data.join("assets").join("signature-2.jpg").to_string_lossy().into_owned();
|
.join("assets")
|
||||||
|
.join("logo-1.png")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let abs_sig = data
|
||||||
|
.join("assets")
|
||||||
|
.join("signature-2.jpg")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
let foreign = "/somewhere/else/logo.png";
|
let foreign = "/somewhere/else/logo.png";
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE app_settings SET logo_path = ?1, signature_path = ?2",
|
"UPDATE app_settings SET logo_path = ?1, signature_path = ?2",
|
||||||
@@ -1146,9 +1279,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
relativize_asset_paths(&conn, data).unwrap();
|
relativize_asset_paths(&conn, data).unwrap();
|
||||||
let (logo, sig): (String, String) = conn
|
let (logo, sig): (String, String) = conn
|
||||||
.query_row("SELECT logo_path, signature_path FROM app_settings", [], |r| {
|
.query_row(
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
"SELECT logo_path, signature_path FROM app_settings",
|
||||||
})
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(logo, "assets/logo-1.png");
|
assert_eq!(logo, "assets/logo-1.png");
|
||||||
assert_eq!(sig, foreign);
|
assert_eq!(sig, foreign);
|
||||||
@@ -1165,7 +1300,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
assert_eq!(json["signaturePath"], "assets/signature-2.jpg");
|
assert_eq!(json["signaturePath"], "assets/signature-2.jpg");
|
||||||
assert_eq!(json["vendorName"], "Acme");
|
assert_eq!(json["vendorName"], "Acme");
|
||||||
let bad: String = conn
|
let bad: String = conn
|
||||||
.query_row("SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'", [], |r| r.get(0))
|
.query_row(
|
||||||
|
"SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(bad, "not json");
|
assert_eq!(bad, "not json");
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-12
@@ -75,7 +75,11 @@ pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str
|
|||||||
if registration == "unregistered" {
|
if registration == "unregistered" {
|
||||||
return TaxType::None;
|
return TaxType::None;
|
||||||
}
|
}
|
||||||
let pos = if pos_state.trim().is_empty() { supplier_state } else { pos_state };
|
let pos = if pos_state.trim().is_empty() {
|
||||||
|
supplier_state
|
||||||
|
} else {
|
||||||
|
pos_state
|
||||||
|
};
|
||||||
if pos == supplier_state {
|
if pos == supplier_state {
|
||||||
TaxType::CgstSgst
|
TaxType::CgstSgst
|
||||||
} else {
|
} else {
|
||||||
@@ -225,8 +229,26 @@ pub fn compute_totals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ONES: [&str; 20] = [
|
const ONES: [&str; 20] = [
|
||||||
"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven",
|
"",
|
||||||
"Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen",
|
"One",
|
||||||
|
"Two",
|
||||||
|
"Three",
|
||||||
|
"Four",
|
||||||
|
"Five",
|
||||||
|
"Six",
|
||||||
|
"Seven",
|
||||||
|
"Eight",
|
||||||
|
"Nine",
|
||||||
|
"Ten",
|
||||||
|
"Eleven",
|
||||||
|
"Twelve",
|
||||||
|
"Thirteen",
|
||||||
|
"Fourteen",
|
||||||
|
"Fifteen",
|
||||||
|
"Sixteen",
|
||||||
|
"Seventeen",
|
||||||
|
"Eighteen",
|
||||||
|
"Nineteen",
|
||||||
];
|
];
|
||||||
|
|
||||||
const TENS: [&str; 10] = [
|
const TENS: [&str; 10] = [
|
||||||
@@ -353,9 +375,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tax_type_serde_strings() {
|
fn tax_type_serde_strings() {
|
||||||
assert_eq!(serde_json::to_string(&TaxType::CgstSgst).unwrap(), "\"cgst_sgst\"");
|
assert_eq!(
|
||||||
assert_eq!(serde_json::from_str::<TaxType>("\"igst\"").unwrap(), TaxType::Igst);
|
serde_json::to_string(&TaxType::CgstSgst).unwrap(),
|
||||||
assert_eq!(serde_json::from_str::<TaxType>("\"none\"").unwrap(), TaxType::None);
|
"\"cgst_sgst\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<TaxType>("\"igst\"").unwrap(),
|
||||||
|
TaxType::Igst
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<TaxType>("\"none\"").unwrap(),
|
||||||
|
TaxType::None
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -384,17 +415,29 @@ mod tests {
|
|||||||
assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(()));
|
assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(()));
|
||||||
// The algorithm gives W for this body, so a trailing 5 is a typo.
|
// The algorithm gives W for this body, so a trailing 5 is a typo.
|
||||||
assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(()));
|
assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(()));
|
||||||
assert!(validate_gstin("29ABCDE1234F1Z5").unwrap_err().contains("check digit"));
|
assert!(validate_gstin("29ABCDE1234F1Z5")
|
||||||
assert!(validate_gstin("27AAPFU0939F1ZX").unwrap_err().contains("check digit"));
|
.unwrap_err()
|
||||||
|
.contains("check digit"));
|
||||||
|
assert!(validate_gstin("27AAPFU0939F1ZX")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("check digit"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gstin_rejects_bad_shapes() {
|
fn gstin_rejects_bad_shapes() {
|
||||||
assert!(validate_gstin("").unwrap_err().contains("15 characters"));
|
assert!(validate_gstin("").unwrap_err().contains("15 characters"));
|
||||||
assert!(validate_gstin("27AAPFU0939F1Z").unwrap_err().contains("15 characters"));
|
assert!(validate_gstin("27AAPFU0939F1Z")
|
||||||
assert!(validate_gstin("27AAPFU0939F1ZVV").unwrap_err().contains("15 characters"));
|
.unwrap_err()
|
||||||
assert!(validate_gstin("2XAAPFU0939F1ZV").unwrap_err().contains("format"));
|
.contains("15 characters"));
|
||||||
assert!(validate_gstin("27AAPFU0939F1AV").unwrap_err().contains("format"));
|
assert!(validate_gstin("27AAPFU0939F1ZVV")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("15 characters"));
|
||||||
|
assert!(validate_gstin("2XAAPFU0939F1ZV")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("format"));
|
||||||
|
assert!(validate_gstin("27AAPFU0939F1AV")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("format"));
|
||||||
// Valid shape and checksum but state 99 does not exist.
|
// Valid shape and checksum but state 99 does not exist.
|
||||||
let body = b"99AAPFU0939F1Z";
|
let body = b"99AAPFU0939F1Z";
|
||||||
let mut g = String::from_utf8(body.to_vec()).unwrap();
|
let mut g = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ impl Default for ClientOptions {
|
|||||||
/// Plain http is only for a local development site; anything else must use https.
|
/// Plain http is only for a local development site; anything else must use https.
|
||||||
pub fn is_local_dev_host(host: &str) -> bool {
|
pub fn is_local_dev_host(host: &str) -> bool {
|
||||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||||
host == "localhost" || host == "127.0.0.1" || host.ends_with(".localhost") || host.ends_with(".test")
|
host == "localhost"
|
||||||
|
|| host == "127.0.0.1"
|
||||||
|
|| host.ends_with(".localhost")
|
||||||
|
|| host.ends_with(".test")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash.
|
/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash.
|
||||||
@@ -63,10 +66,15 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
|
|||||||
raw.to_string()
|
raw.to_string()
|
||||||
} else {
|
} else {
|
||||||
let host = raw.split(['/', ':']).next().unwrap_or("");
|
let host = raw.split(['/', ':']).next().unwrap_or("");
|
||||||
let scheme = if is_local_dev_host(host) { "http" } else { "https" };
|
let scheme = if is_local_dev_host(host) {
|
||||||
|
"http"
|
||||||
|
} else {
|
||||||
|
"https"
|
||||||
|
};
|
||||||
format!("{scheme}://{raw}")
|
format!("{scheme}://{raw}")
|
||||||
};
|
};
|
||||||
let mut url = Url::parse(&with_scheme).map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
|
let mut url = Url::parse(&with_scheme)
|
||||||
|
.map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
|
||||||
let host = url
|
let host = url
|
||||||
.host_str()
|
.host_str()
|
||||||
.ok_or_else(|| ErpError::config("The ERPNext address has no host name."))?
|
.ok_or_else(|| ErpError::config("The ERPNext address has no host name."))?
|
||||||
@@ -80,10 +88,16 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
other => return Err(ErpError::config(format!("Unsupported address scheme \"{other}\"; use https://."))),
|
other => {
|
||||||
|
return Err(ErpError::config(format!(
|
||||||
|
"Unsupported address scheme \"{other}\"; use https://."
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !url.username().is_empty() || url.password().is_some() {
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
return Err(ErpError::config("Do not put credentials in the address; use the API key and secret fields."));
|
return Err(ErpError::config(
|
||||||
|
"Do not put credentials in the address; use the API key and secret fields.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
url.set_query(None);
|
url.set_query(None);
|
||||||
url.set_fragment(None);
|
url.set_fragment(None);
|
||||||
@@ -114,7 +128,9 @@ pub struct ErpClient {
|
|||||||
|
|
||||||
impl std::fmt::Debug for ErpClient {
|
impl std::fmt::Debug for ErpClient {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("ErpClient").field("base", &self.base.as_str()).finish_non_exhaustive()
|
f.debug_struct("ErpClient")
|
||||||
|
.field("base", &self.base.as_str())
|
||||||
|
.finish_non_exhaustive()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,12 +143,18 @@ impl ErpClient {
|
|||||||
opts: ClientOptions,
|
opts: ClientOptions,
|
||||||
) -> Result<Self, ErpError> {
|
) -> Result<Self, ErpError> {
|
||||||
ensure_crypto_provider();
|
ensure_crypto_provider();
|
||||||
let base = Url::parse(&normalize_base_url(base_url)?).map_err(|e| ErpError::config(e.to_string()))?;
|
let base = Url::parse(&normalize_base_url(base_url)?)
|
||||||
|
.map_err(|e| ErpError::config(e.to_string()))?;
|
||||||
if api_key.trim().is_empty() || !api_secret.is_set() {
|
if api_key.trim().is_empty() || !api_secret.is_set() {
|
||||||
return Err(ErpError::config("Enter the API key and API secret."));
|
return Err(ErpError::config("Enter the API key and API secret."));
|
||||||
}
|
}
|
||||||
let mut auth = HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose()))
|
let mut auth =
|
||||||
.map_err(|_| ErpError::config("The API key or secret contains characters that cannot be sent."))?;
|
HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose()))
|
||||||
|
.map_err(|_| {
|
||||||
|
ErpError::config(
|
||||||
|
"The API key or secret contains characters that cannot be sent.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
auth.set_sensitive(true);
|
auth.set_sensitive(true);
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
@@ -152,13 +174,18 @@ impl ErpClient {
|
|||||||
let certs = reqwest::Certificate::from_pem_bundle(extra_ca_pem.trim().as_bytes())
|
let certs = reqwest::Certificate::from_pem_bundle(extra_ca_pem.trim().as_bytes())
|
||||||
.map_err(|_| ErpError::config("The extra CA certificate is not valid PEM."))?;
|
.map_err(|_| ErpError::config("The extra CA certificate is not valid PEM."))?;
|
||||||
if certs.is_empty() {
|
if certs.is_empty() {
|
||||||
return Err(ErpError::config("The extra CA certificate field holds no certificate."));
|
return Err(ErpError::config(
|
||||||
|
"The extra CA certificate field holds no certificate.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
builder = builder.tls_certs_merge(certs);
|
builder = builder.tls_certs_merge(certs);
|
||||||
}
|
}
|
||||||
let http = builder
|
let http = builder.build().map_err(|e| {
|
||||||
.build()
|
ErpError::config(format!(
|
||||||
.map_err(|e| ErpError::config(format!("Could not set up the HTTP client: {}", error_chain(&e))))?;
|
"Could not set up the HTTP client: {}",
|
||||||
|
error_chain(&e)
|
||||||
|
))
|
||||||
|
})?;
|
||||||
Ok(ErpClient {
|
Ok(ErpClient {
|
||||||
http,
|
http,
|
||||||
base,
|
base,
|
||||||
@@ -168,7 +195,13 @@ impl ErpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_config(cfg: &ErpnextConfig) -> Result<Self, ErpError> {
|
pub fn from_config(cfg: &ErpnextConfig) -> Result<Self, ErpError> {
|
||||||
Self::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, &cfg.extra_ca_pem, ClientOptions::default())
|
Self::new(
|
||||||
|
&cfg.base_url,
|
||||||
|
&cfg.api_key,
|
||||||
|
&cfg.api_secret,
|
||||||
|
&cfg.extra_ca_pem,
|
||||||
|
ClientOptions::default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn base_url(&self) -> &str {
|
pub fn base_url(&self) -> &str {
|
||||||
@@ -184,22 +217,38 @@ impl ErpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET, retried on 429, 5xx and timeouts.
|
/// GET, retried on 429, 5xx and timeouts.
|
||||||
pub async fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result<Value, ErpError> {
|
pub async fn get(
|
||||||
self.send(Method::GET, segments, query, Payload::None, true).await
|
&self,
|
||||||
|
segments: &[&str],
|
||||||
|
query: &[(&str, String)],
|
||||||
|
) -> Result<Value, ErpError> {
|
||||||
|
self.send(Method::GET, segments, query, Payload::None, true)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST with a JSON body. 429 is always retried (the server refused before doing anything). 5xx and
|
/// POST with a JSON body. 429 is always retried (the server refused before doing anything). 5xx and
|
||||||
/// timeouts are retried only when `idempotent` is true, because the server may have processed the
|
/// timeouts are retried only when `idempotent` is true, because the server may have processed the
|
||||||
/// request: a mirrored-name Sales Invoice is idempotent (a repeat is a 409), a series-named one is not.
|
/// request: a mirrored-name Sales Invoice is idempotent (a repeat is a 409), a series-named one is not.
|
||||||
pub async fn post(&self, segments: &[&str], body: &Value, idempotent: bool) -> Result<Value, ErpError> {
|
pub async fn post(
|
||||||
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent).await
|
&self,
|
||||||
|
segments: &[&str],
|
||||||
|
body: &Value,
|
||||||
|
idempotent: bool,
|
||||||
|
) -> Result<Value, ErpError> {
|
||||||
|
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file
|
/// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file
|
||||||
/// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or
|
/// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or
|
||||||
/// timeout, because an upload that may have landed would attach the file twice.
|
/// timeout, because an upload that may have landed would attach the file twice.
|
||||||
pub async fn post_file(&self, segments: &[&str], upload: &Upload<'_>) -> Result<Value, ErpError> {
|
pub async fn post_file(
|
||||||
self.send(Method::POST, segments, &[], Payload::Upload(upload), false).await
|
&self,
|
||||||
|
segments: &[&str],
|
||||||
|
upload: &Upload<'_>,
|
||||||
|
) -> Result<Value, ErpError> {
|
||||||
|
self.send(Method::POST, segments, &[], Payload::Upload(upload), false)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(
|
async fn send(
|
||||||
@@ -260,7 +309,8 @@ impl ErpClient {
|
|||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
return self.parse_success(&text);
|
return self.parse_success(&text);
|
||||||
}
|
}
|
||||||
let retryable = status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
|
let retryable =
|
||||||
|
status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
|
||||||
if retryable && can_retry {
|
if retryable && can_retry {
|
||||||
self.backoff(attempt, retry_after).await;
|
self.backoff(attempt, retry_after).await;
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
@@ -287,7 +337,10 @@ impl ErpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) {
|
async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) {
|
||||||
let exp = self.opts.backoff_base.saturating_mul(1u32 << attempt.min(10));
|
let exp = self
|
||||||
|
.opts
|
||||||
|
.backoff_base
|
||||||
|
.saturating_mul(1u32 << attempt.min(10));
|
||||||
let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff);
|
let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff);
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
@@ -311,7 +364,10 @@ impl ErpClient {
|
|||||||
fn map_transport(&self, e: &reqwest::Error) -> ErpError {
|
fn map_transport(&self, e: &reqwest::Error) -> ErpError {
|
||||||
let chain = error_chain(e);
|
let chain = error_chain(e);
|
||||||
let err = if e.is_timeout() {
|
let err = if e.is_timeout() {
|
||||||
ErpError::new(ErrorKind::Timeout, "The ERPNext server did not answer in time.")
|
ErpError::new(
|
||||||
|
ErrorKind::Timeout,
|
||||||
|
"The ERPNext server did not answer in time.",
|
||||||
|
)
|
||||||
} else if e.is_connect() {
|
} else if e.is_connect() {
|
||||||
let lower = chain.to_ascii_lowercase();
|
let lower = chain.to_ascii_lowercase();
|
||||||
let hint = if lower.contains("certificate") || lower.contains("unknownissuer") {
|
let hint = if lower.contains("certificate") || lower.contains("unknownissuer") {
|
||||||
@@ -319,9 +375,15 @@ impl ErpClient {
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
ErpError::new(ErrorKind::Network, format!("Could not connect to the ERPNext server ({chain}).{hint}"))
|
ErpError::new(
|
||||||
|
ErrorKind::Network,
|
||||||
|
format!("Could not connect to the ERPNext server ({chain}).{hint}"),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
ErpError::new(ErrorKind::Network, format!("The request to ERPNext failed ({chain})."))
|
ErpError::new(
|
||||||
|
ErrorKind::Network,
|
||||||
|
format!("The request to ERPNext failed ({chain})."),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
self.scrub(err)
|
self.scrub(err)
|
||||||
}
|
}
|
||||||
@@ -352,9 +414,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn https_is_accepted_and_normalised() {
|
fn https_is_accepted_and_normalised() {
|
||||||
assert_eq!(normalize_base_url(" https://Erp.Example.com/ ").unwrap(), "https://erp.example.com");
|
assert_eq!(
|
||||||
assert_eq!(normalize_base_url("erp.example.com").unwrap(), "https://erp.example.com");
|
normalize_base_url(" https://Erp.Example.com/ ").unwrap(),
|
||||||
assert_eq!(normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(), "https://erp.example.com:8443/sub");
|
"https://erp.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_base_url("erp.example.com").unwrap(),
|
||||||
|
"https://erp.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(),
|
||||||
|
"https://erp.example.com:8443/sub"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -370,14 +441,26 @@ mod tests {
|
|||||||
let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}"));
|
let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}"));
|
||||||
assert!(url.starts_with("http://"), "{ok} -> {url}");
|
assert!(url.starts_with("http://"), "{ok} -> {url}");
|
||||||
}
|
}
|
||||||
for bad in ["http://erp.example.com", "http://192.168.1.10:8000", "http://localhost.evil.com", "http://notlocalhost"] {
|
for bad in [
|
||||||
|
"http://erp.example.com",
|
||||||
|
"http://192.168.1.10:8000",
|
||||||
|
"http://localhost.evil.com",
|
||||||
|
"http://notlocalhost",
|
||||||
|
] {
|
||||||
assert!(normalize_base_url(bad).is_err(), "{bad} must be refused");
|
assert!(normalize_base_url(bad).is_err(), "{bad} must be refused");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn other_schemes_credentials_and_blanks_are_refused() {
|
fn other_schemes_credentials_and_blanks_are_refused() {
|
||||||
for bad in ["ftp://erp.example.com", "file:///etc/passwd", "https://user:pw@erp.example.com", "", " ", "https://"] {
|
for bad in [
|
||||||
|
"ftp://erp.example.com",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"https://user:pw@erp.example.com",
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"https://",
|
||||||
|
] {
|
||||||
assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused");
|
assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,8 +474,14 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(value["message"], "Sample@example.com");
|
assert_eq!(value["message"], "Sample@example.com");
|
||||||
let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase();
|
let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase();
|
||||||
assert!(raw.starts_with("get /api/method/frappe.auth.get_logged_user "), "{raw}");
|
assert!(
|
||||||
assert!(raw.contains("authorization: token thekey:thesecret"), "{raw}");
|
raw.starts_with("get /api/method/frappe.auth.get_logged_user "),
|
||||||
|
"{raw}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
raw.contains("authorization: token thekey:thesecret"),
|
||||||
|
"{raw}"
|
||||||
|
);
|
||||||
assert!(raw.contains("accept: application/json"), "{raw}");
|
assert!(raw.contains("accept: application/json"), "{raw}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,11 +489,19 @@ mod tests {
|
|||||||
async fn path_segments_are_percent_encoded_once() {
|
async fn path_segments_are_percent_encoded_once() {
|
||||||
let mock = serve(vec![reply(200, json!({ "data": [] }))]).await;
|
let mock = serve(vec![reply(200, json!({ "data": [] }))]).await;
|
||||||
client(&mock)
|
client(&mock)
|
||||||
.get(&["api", "resource", "Sales Invoice", "INV/2026-001"], &[("limit_page_length", "0".into())])
|
.get(
|
||||||
|
&["api", "resource", "Sales Invoice", "INV/2026-001"],
|
||||||
|
&[("limit_page_length", "0".into())],
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let raw = mock.requests.lock().unwrap()[0].clone();
|
let raw = mock.requests.lock().unwrap()[0].clone();
|
||||||
assert!(raw.starts_with("GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "), "{raw}");
|
assert!(
|
||||||
|
raw.starts_with(
|
||||||
|
"GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "
|
||||||
|
),
|
||||||
|
"{raw}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -412,15 +509,25 @@ mod tests {
|
|||||||
let mut limited = reply(429, json!({ "message": "slow down" }));
|
let mut limited = reply(429, json!({ "message": "slow down" }));
|
||||||
limited.headers.push(("Retry-After", "0".into()));
|
limited.headers.push(("Retry-After", "0".into()));
|
||||||
let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await;
|
let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await;
|
||||||
let value = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap();
|
let value = client(&mock)
|
||||||
|
.get(&["api", "method", "ping"], &[])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(value["message"], "ok");
|
assert_eq!(value["message"], "ok");
|
||||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn persistent_500_exhausts_the_retries() {
|
async fn persistent_500_exhausts_the_retries() {
|
||||||
let mock = serve(vec![reply(500, json!({ "exception": "frappe.exceptions.ValidationError: boom" }))]).await;
|
let mock = serve(vec![reply(
|
||||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
500,
|
||||||
|
json!({ "exception": "frappe.exceptions.ValidationError: boom" }),
|
||||||
|
)])
|
||||||
|
.await;
|
||||||
|
let err = client(&mock)
|
||||||
|
.get(&["api", "method", "ping"], &[])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Server);
|
assert_eq!(err.kind, ErrorKind::Server);
|
||||||
assert_eq!(err.message, "boom");
|
assert_eq!(err.message, "boom");
|
||||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries");
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries");
|
||||||
@@ -428,9 +535,17 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn validation_errors_are_not_retried() {
|
async fn validation_errors_are_not_retried() {
|
||||||
let mock = serve(vec![reply(417, json!({ "_error_message": "Rate is required" }))]).await;
|
let mock = serve(vec![reply(
|
||||||
|
417,
|
||||||
|
json!({ "_error_message": "Rate is required" }),
|
||||||
|
)])
|
||||||
|
.await;
|
||||||
let err = client(&mock)
|
let err = client(&mock)
|
||||||
.post(&["api", "resource", "Sales Invoice"], &json!({ "a": 1 }), true)
|
.post(
|
||||||
|
&["api", "resource", "Sales Invoice"],
|
||||||
|
&json!({ "a": 1 }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Validation);
|
assert_eq!(err.kind, ErrorKind::Validation);
|
||||||
@@ -440,25 +555,42 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() {
|
async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() {
|
||||||
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) };
|
let slow = Reply {
|
||||||
|
delay_ms: 2_000,
|
||||||
|
..reply(200, json!({}))
|
||||||
|
};
|
||||||
let mock = serve(vec![slow]).await;
|
let mock = serve(vec![slow]).await;
|
||||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
let err = client(&mock)
|
||||||
|
.get(&["api", "method", "ping"], &[])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Timeout);
|
assert_eq!(err.kind, ErrorKind::Timeout);
|
||||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 4);
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 4);
|
||||||
|
|
||||||
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) };
|
let slow = Reply {
|
||||||
|
delay_ms: 2_000,
|
||||||
|
..reply(200, json!({}))
|
||||||
|
};
|
||||||
let mock = serve(vec![slow]).await;
|
let mock = serve(vec![slow]).await;
|
||||||
let err = client(&mock)
|
let err = client(&mock)
|
||||||
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Timeout);
|
assert_eq!(err.kind, ErrorKind::Timeout);
|
||||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 1, "a write that may have landed is not repeated");
|
assert_eq!(
|
||||||
|
mock.hits.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"a write that may have landed is not repeated"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn non_idempotent_write_retries_429_but_not_500() {
|
async fn non_idempotent_write_retries_429_but_not_500() {
|
||||||
let mock = serve(vec![reply(429, json!({})), reply(200, json!({ "data": { "name": "X" } }))]).await;
|
let mock = serve(vec![
|
||||||
|
reply(429, json!({})),
|
||||||
|
reply(200, json!({ "data": { "name": "X" } })),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
let value = client(&mock)
|
let value = client(&mock)
|
||||||
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
|
||||||
.await
|
.await
|
||||||
@@ -478,11 +610,20 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn redirects_are_reported_not_followed() {
|
async fn redirects_are_reported_not_followed() {
|
||||||
let mut moved = reply(301, json!({}));
|
let mut moved = reply(301, json!({}));
|
||||||
moved.headers.push(("Location", "https://erp.example.com/".into()));
|
moved
|
||||||
|
.headers
|
||||||
|
.push(("Location", "https://erp.example.com/".into()));
|
||||||
let mock = serve(vec![moved]).await;
|
let mock = serve(vec![moved]).await;
|
||||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
let err = client(&mock)
|
||||||
|
.get(&["api", "method", "ping"], &[])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Protocol);
|
assert_eq!(err.kind, ErrorKind::Protocol);
|
||||||
assert!(err.message.contains("https://erp.example.com/"), "{}", err.message);
|
assert!(
|
||||||
|
err.message.contains("https://erp.example.com/"),
|
||||||
|
"{}",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +632,10 @@ mod tests {
|
|||||||
let mut page = reply(200, json!({}));
|
let mut page = reply(200, json!({}));
|
||||||
page.body = "<html>login</html>".into();
|
page.body = "<html>login</html>".into();
|
||||||
let mock = serve(vec![page]).await;
|
let mock = serve(vec![page]).await;
|
||||||
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
let err = client(&mock)
|
||||||
|
.get(&["api", "method", "ping"], &[])
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Protocol);
|
assert_eq!(err.kind, ErrorKind::Protocol);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +644,14 @@ mod tests {
|
|||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let port = listener.local_addr().unwrap().port();
|
let port = listener.local_addr().unwrap().port();
|
||||||
drop(listener);
|
drop(listener);
|
||||||
let c = ErpClient::new(&format!("http://127.0.0.1:{port}"), "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap();
|
let c = ErpClient::new(
|
||||||
|
&format!("http://127.0.0.1:{port}"),
|
||||||
|
"thekey",
|
||||||
|
&Secret::new("thesecret"),
|
||||||
|
"",
|
||||||
|
fast_opts(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Network);
|
assert_eq!(err.kind, ErrorKind::Network);
|
||||||
assert!(!err.to_string().contains("thesecret"));
|
assert!(!err.to_string().contains("thesecret"));
|
||||||
@@ -509,9 +660,30 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn credentials_are_required_and_a_bad_ca_is_refused() {
|
fn credentials_are_required_and_a_bad_ca_is_refused() {
|
||||||
assert!(ErpClient::new("https://erp.example.com", "", &Secret::new("s"), "", fast_opts()).is_err());
|
assert!(ErpClient::new(
|
||||||
assert!(ErpClient::new("https://erp.example.com", "k", &Secret::default(), "", fast_opts()).is_err());
|
"https://erp.example.com",
|
||||||
let err = ErpClient::new("https://erp.example.com", "k", &Secret::new("s"), "not a certificate", fast_opts()).unwrap_err();
|
"",
|
||||||
|
&Secret::new("s"),
|
||||||
|
"",
|
||||||
|
fast_opts()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(ErpClient::new(
|
||||||
|
"https://erp.example.com",
|
||||||
|
"k",
|
||||||
|
&Secret::default(),
|
||||||
|
"",
|
||||||
|
fast_opts()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
let err = ErpClient::new(
|
||||||
|
"https://erp.example.com",
|
||||||
|
"k",
|
||||||
|
&Secret::new("s"),
|
||||||
|
"not a certificate",
|
||||||
|
fast_opts(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Config);
|
assert_eq!(err.kind, ErrorKind::Config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ impl Secret {
|
|||||||
|
|
||||||
impl std::fmt::Debug for Secret {
|
impl std::fmt::Debug for Secret {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.write_str(if self.is_set() { "Secret([redacted])" } else { "Secret(unset)" })
|
f.write_str(if self.is_set() {
|
||||||
|
"Secret([redacted])"
|
||||||
|
} else {
|
||||||
|
"Secret(unset)"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +154,9 @@ impl ErpnextConfig {
|
|||||||
if let Some(u) = self.uom_map.get(unit).filter(|u| !u.trim().is_empty()) {
|
if let Some(u) = self.uom_map.get(unit).filter(|u| !u.trim().is_empty()) {
|
||||||
return u.trim().to_string();
|
return u.trim().to_string();
|
||||||
}
|
}
|
||||||
default_uom_map().remove(unit).unwrap_or_else(|| "Nos".to_string())
|
default_uom_map()
|
||||||
|
.remove(unit)
|
||||||
|
.unwrap_or_else(|| "Nos".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view(&self) -> ErpnextConfigView {
|
pub fn view(&self) -> ErpnextConfigView {
|
||||||
@@ -182,7 +188,8 @@ impl ErpnextConfig {
|
|||||||
attach_pdf: self.attach_pdf,
|
attach_pdf: self.attach_pdf,
|
||||||
auto_push_on_issue: self.auto_push_on_issue,
|
auto_push_on_issue: self.auto_push_on_issue,
|
||||||
create_missing_customers: self.create_missing_customers,
|
create_missing_customers: self.create_missing_customers,
|
||||||
last_detect_result: serde_json::from_str(&self.last_detect_result).unwrap_or(Value::Null),
|
last_detect_result: serde_json::from_str(&self.last_detect_result)
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +303,11 @@ fn yes() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn origin_of(url: &str) -> String {
|
fn origin_of(url: &str) -> String {
|
||||||
url.split('/').take(3).collect::<Vec<_>>().join("/").to_ascii_lowercase()
|
url.split('/')
|
||||||
|
.take(3)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
.to_ascii_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ErpnextConfigInput {
|
impl ErpnextConfigInput {
|
||||||
@@ -469,8 +480,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
|
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
|
||||||
let mut base = serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" });
|
let mut base =
|
||||||
base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" });
|
||||||
|
base.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.extend(extra.as_object().unwrap().clone());
|
||||||
serde_json::from_value(base).unwrap()
|
serde_json::from_value(base).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +494,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_never_prints_the_secret() {
|
fn debug_never_prints_the_secret() {
|
||||||
let cfg = ErpnextConfig { api_secret: Secret::new("s3cr3t-value"), ..Default::default() };
|
let cfg = ErpnextConfig {
|
||||||
|
api_secret: Secret::new("s3cr3t-value"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(!format!("{cfg:?}").contains("s3cr3t-value"));
|
assert!(!format!("{cfg:?}").contains("s3cr3t-value"));
|
||||||
assert!(!format!("{:?}", Secret::new("s3cr3t-value")).contains("s3cr3t-value"));
|
assert!(!format!("{:?}", Secret::new("s3cr3t-value")).contains("s3cr3t-value"));
|
||||||
}
|
}
|
||||||
@@ -489,7 +506,12 @@ mod tests {
|
|||||||
fn defaults_load_from_the_migration_row() {
|
fn defaults_load_from_the_migration_row() {
|
||||||
let cfg = load(&db()).unwrap();
|
let cfg = load(&db()).unwrap();
|
||||||
assert_eq!(cfg.naming_mode, NamingMode::Mirror);
|
assert_eq!(cfg.naming_mode, NamingMode::Mirror);
|
||||||
assert!(!cfg.submit_on_push && cfg.attach_pdf && !cfg.auto_push_on_issue && cfg.create_missing_customers);
|
assert!(
|
||||||
|
!cfg.submit_on_push
|
||||||
|
&& cfg.attach_pdf
|
||||||
|
&& !cfg.auto_push_on_issue
|
||||||
|
&& cfg.create_missing_customers
|
||||||
|
);
|
||||||
assert_eq!(cfg.uom_for("hour"), "Hour");
|
assert_eq!(cfg.uom_for("hour"), "Hour");
|
||||||
assert_eq!(cfg.uom_for("session"), "Nos");
|
assert_eq!(cfg.uom_for("session"), "Nos");
|
||||||
assert!(!cfg.api_secret.is_set());
|
assert!(!cfg.api_secret.is_set());
|
||||||
@@ -519,34 +541,52 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn blank_secret_keeps_and_explicit_values_replace_or_clear() {
|
fn blank_secret_keeps_and_explicit_values_replace_or_clear() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap();
|
let first = input(serde_json::json!({ "apiSecret": "one" }))
|
||||||
|
.resolve(&load(&conn).unwrap())
|
||||||
|
.unwrap();
|
||||||
save(&conn, &first).unwrap();
|
save(&conn, &first).unwrap();
|
||||||
|
|
||||||
let keep = input(serde_json::json!({ "apiSecret": " " })).resolve(&load(&conn).unwrap()).unwrap();
|
let keep = input(serde_json::json!({ "apiSecret": " " }))
|
||||||
|
.resolve(&load(&conn).unwrap())
|
||||||
|
.unwrap();
|
||||||
assert_eq!(keep.api_secret.expose(), "one");
|
assert_eq!(keep.api_secret.expose(), "one");
|
||||||
let replace = input(serde_json::json!({ "apiSecret": "two" })).resolve(&load(&conn).unwrap()).unwrap();
|
let replace = input(serde_json::json!({ "apiSecret": "two" }))
|
||||||
|
.resolve(&load(&conn).unwrap())
|
||||||
|
.unwrap();
|
||||||
assert_eq!(replace.api_secret.expose(), "two");
|
assert_eq!(replace.api_secret.expose(), "two");
|
||||||
let clear = input(serde_json::json!({ "clearSecret": true })).resolve(&load(&conn).unwrap()).unwrap();
|
let clear = input(serde_json::json!({ "clearSecret": true }))
|
||||||
|
.resolve(&load(&conn).unwrap())
|
||||||
|
.unwrap();
|
||||||
assert!(!clear.api_secret.is_set());
|
assert!(!clear.api_secret.is_set());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stored_secret_is_not_reused_for_another_server() {
|
fn stored_secret_is_not_reused_for_another_server() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap();
|
let first = input(serde_json::json!({ "apiSecret": "one" }))
|
||||||
|
.resolve(&load(&conn).unwrap())
|
||||||
|
.unwrap();
|
||||||
save(&conn, &first).unwrap();
|
save(&conn, &first).unwrap();
|
||||||
let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" })).resolve(&load(&conn).unwrap());
|
let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" }))
|
||||||
|
.resolve(&load(&conn).unwrap());
|
||||||
assert!(moved.unwrap_err().contains("API secret"));
|
assert!(moved.unwrap_err().contains("API secret"));
|
||||||
// The same server with a different path or case is still the same origin.
|
// The same server with a different path or case is still the same origin.
|
||||||
let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" })).resolve(&load(&conn).unwrap());
|
let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" }))
|
||||||
|
.resolve(&load(&conn).unwrap());
|
||||||
assert!(same.is_ok());
|
assert!(same.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_address_or_naming_mode_is_rejected() {
|
fn invalid_address_or_naming_mode_is_rejected() {
|
||||||
let stored = ErpnextConfig::default();
|
let stored = ErpnextConfig::default();
|
||||||
assert!(input(serde_json::json!({ "baseUrl": "http://erp.example.com" })).resolve(&stored).is_err());
|
assert!(
|
||||||
assert!(input(serde_json::json!({ "namingMode": "random" })).resolve(&stored).is_err());
|
input(serde_json::json!({ "baseUrl": "http://erp.example.com" }))
|
||||||
|
.resolve(&stored)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(input(serde_json::json!({ "namingMode": "random" }))
|
||||||
|
.resolve(&stored)
|
||||||
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -20,12 +20,19 @@ impl Version {
|
|||||||
/// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored.
|
/// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored.
|
||||||
pub fn parse(s: &str) -> Option<Version> {
|
pub fn parse(s: &str) -> Option<Version> {
|
||||||
let s = s.trim().trim_start_matches(['v', 'V']);
|
let s = s.trim().trim_start_matches(['v', 'V']);
|
||||||
let numeric: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
|
let numeric: String = s
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_digit() || *c == '.')
|
||||||
|
.collect();
|
||||||
let mut parts = numeric.split('.').filter(|p| !p.is_empty());
|
let mut parts = numeric.split('.').filter(|p| !p.is_empty());
|
||||||
let major = parts.next()?.parse().ok()?;
|
let major = parts.next()?.parse().ok()?;
|
||||||
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
Some(Version { major, minor, patch })
|
Some(Version {
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
patch,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +66,10 @@ pub fn parse_versions(value: &Value) -> ServerVersions {
|
|||||||
let apps = value.get("message").unwrap_or(value);
|
let apps = value.get("message").unwrap_or(value);
|
||||||
let version_of = |app: &str| -> Option<String> {
|
let version_of = |app: &str| -> Option<String> {
|
||||||
let entry = apps.get(app)?;
|
let entry = apps.get(app)?;
|
||||||
let v = entry.get("version").and_then(Value::as_str).or_else(|| entry.as_str())?;
|
let v = entry
|
||||||
|
.get("version")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| entry.as_str())?;
|
||||||
let v = v.trim();
|
let v = v.trim();
|
||||||
(!v.is_empty()).then(|| v.to_string())
|
(!v.is_empty()).then(|| v.to_string())
|
||||||
};
|
};
|
||||||
@@ -77,7 +87,9 @@ pub const IC_MAX_NUMBER_LEN: usize = 16;
|
|||||||
/// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`.
|
/// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`.
|
||||||
pub fn ic_number_ok(number: &str) -> bool {
|
pub fn ic_number_ok(number: &str) -> bool {
|
||||||
let mut chars = number.chars();
|
let mut chars = number.chars();
|
||||||
let Some(first) = chars.next() else { return false };
|
let Some(first) = chars.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
first.is_alphanumeric()
|
first.is_alphanumeric()
|
||||||
&& number.chars().count() <= IC_MAX_NUMBER_LEN
|
&& number.chars().count() <= IC_MAX_NUMBER_LEN
|
||||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/')
|
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/')
|
||||||
@@ -158,13 +170,22 @@ fn item(row: &Value, label_key: &str, detail: String) -> Option<OptionItem> {
|
|||||||
let label = text(row, label_key);
|
let label = text(row, label_key);
|
||||||
Some(OptionItem {
|
Some(OptionItem {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
label: if label.is_empty() { name.to_string() } else { label.to_string() },
|
label: if label.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
label.to_string()
|
||||||
|
},
|
||||||
detail,
|
detail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn join(parts: &[&str]) -> String {
|
fn join(parts: &[&str]) -> String {
|
||||||
parts.iter().filter(|p| !p.is_empty()).cloned().collect::<Vec<_>>().join(" · ")
|
parts
|
||||||
|
.iter()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank.
|
/// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank.
|
||||||
@@ -173,23 +194,41 @@ pub fn parse_naming_series(doctype_response: &Value) -> Vec<OptionItem> {
|
|||||||
.get("docs")
|
.get("docs")
|
||||||
.or_else(|| doctype_response.get("message").and_then(|m| m.get("docs")))
|
.or_else(|| doctype_response.get("message").and_then(|m| m.get("docs")))
|
||||||
.and_then(Value::as_array);
|
.and_then(Value::as_array);
|
||||||
let Some(doc) = docs.and_then(|d| d.first()) else { return Vec::new() };
|
let Some(doc) = docs.and_then(|d| d.first()) else {
|
||||||
let Some(fields) = doc.get("fields").and_then(Value::as_array) else { return Vec::new() };
|
return Vec::new();
|
||||||
let Some(field) = fields.iter().find(|f| text(f, "fieldname") == "naming_series") else {
|
};
|
||||||
|
let Some(fields) = doc.get("fields").and_then(Value::as_array) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(field) = fields
|
||||||
|
.iter()
|
||||||
|
.find(|f| text(f, "fieldname") == "naming_series")
|
||||||
|
else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
text(field, "options")
|
text(field, "options")
|
||||||
.lines()
|
.lines()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|l| !l.is_empty())
|
.filter(|l| !l.is_empty())
|
||||||
.map(|l| OptionItem { name: l.to_string(), label: l.to_string(), detail: format!("e.g. {} digits", naming_series_expanded_len(l)) })
|
.map(|l| OptionItem {
|
||||||
|
name: l.to_string(),
|
||||||
|
label: l.to_string(),
|
||||||
|
detail: format!("e.g. {} digits", naming_series_expanded_len(l)),
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ErpClient {
|
impl ErpClient {
|
||||||
pub async fn get_logged_user(&self) -> Result<String, ErpError> {
|
pub async fn get_logged_user(&self) -> Result<String, ErpError> {
|
||||||
let v = self.get(&["api", "method", "frappe.auth.get_logged_user"], &[]).await?;
|
let v = self
|
||||||
let user = v.get("message").and_then(Value::as_str).unwrap_or("").trim().to_string();
|
.get(&["api", "method", "frappe.auth.get_logged_user"], &[])
|
||||||
|
.await?;
|
||||||
|
let user = v
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
if user.is_empty() || user.eq_ignore_ascii_case("guest") {
|
if user.is_empty() || user.eq_ignore_ascii_case("guest") {
|
||||||
return Err(ErpError {
|
return Err(ErpError {
|
||||||
kind: ErrorKind::Auth,
|
kind: ErrorKind::Auth,
|
||||||
@@ -202,7 +241,12 @@ impl ErpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_versions(&self) -> Result<ServerVersions, ErpError> {
|
pub async fn get_versions(&self) -> Result<ServerVersions, ErpError> {
|
||||||
let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?;
|
let v = self
|
||||||
|
.get(
|
||||||
|
&["api", "method", "frappe.utils.change_log.get_versions"],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(parse_versions(&v))
|
Ok(parse_versions(&v))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +267,10 @@ impl ErpClient {
|
|||||||
query.push(("filters", filters.to_string()));
|
query.push(("filters", filters.to_string()));
|
||||||
}
|
}
|
||||||
let v = self.get(&["api", "resource", doctype], &query).await?;
|
let v = self.get(&["api", "resource", doctype], &query).await?;
|
||||||
Ok(v.get("data").and_then(Value::as_array).cloned().unwrap_or_default())
|
Ok(v.get("data")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_options(&self, company: &str) -> Result<ErpnextOptions, ErpError> {
|
pub async fn load_options(&self, company: &str) -> Result<ErpnextOptions, ErpError> {
|
||||||
@@ -234,50 +281,117 @@ impl ErpClient {
|
|||||||
($list:literal, $target:expr, $call:expr) => {
|
($list:literal, $target:expr, $call:expr) => {
|
||||||
match $call.await {
|
match $call.await {
|
||||||
Ok(rows) => $target = rows,
|
Ok(rows) => $target = rows,
|
||||||
Err(e) if matches!(e.kind, ErrorKind::Auth | ErrorKind::Network | ErrorKind::Timeout | ErrorKind::Config | ErrorKind::Protocol) => {
|
Err(e)
|
||||||
|
if matches!(
|
||||||
|
e.kind,
|
||||||
|
ErrorKind::Auth
|
||||||
|
| ErrorKind::Network
|
||||||
|
| ErrorKind::Timeout
|
||||||
|
| ErrorKind::Config
|
||||||
|
| ErrorKind::Protocol
|
||||||
|
) =>
|
||||||
|
{
|
||||||
return Err(e)
|
return Err(e)
|
||||||
}
|
}
|
||||||
Err(e) => out.errors.push(OptionError { list: $list.to_string(), message: e.to_string() }),
|
Err(e) => out.errors.push(OptionError {
|
||||||
|
list: $list.to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
load!("companies", out.companies, async {
|
load!("companies", out.companies, async {
|
||||||
self.list_resource("Company", &["name", "company_name", "abbr", "country", "default_currency"], Value::Null, "name asc")
|
self.list_resource(
|
||||||
|
"Company",
|
||||||
|
&[
|
||||||
|
"name",
|
||||||
|
"company_name",
|
||||||
|
"abbr",
|
||||||
|
"country",
|
||||||
|
"default_currency",
|
||||||
|
],
|
||||||
|
Value::Null,
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| {
|
.map(|rows| {
|
||||||
rows.iter()
|
rows.iter()
|
||||||
.filter_map(|r| item(r, "company_name", join(&[text(r, "abbr"), text(r, "default_currency")])))
|
.filter_map(|r| {
|
||||||
|
item(
|
||||||
|
r,
|
||||||
|
"company_name",
|
||||||
|
join(&[text(r, "abbr"), text(r, "default_currency")]),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
load!("itemGroups", out.item_groups, async {
|
load!("itemGroups", out.item_groups, async {
|
||||||
self.list_resource("Item Group", &["name"], Value::Null, "name asc")
|
self.list_resource("Item Group", &["name"], Value::Null, "name asc")
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("uoms", out.uoms, async {
|
load!("uoms", out.uoms, async {
|
||||||
self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc")
|
self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc")
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("priceLists", out.price_lists, async {
|
load!("priceLists", out.price_lists, async {
|
||||||
self.list_resource("Price List", &["name"], json!([["enabled", "=", 1], ["selling", "=", 1]]), "name asc")
|
self.list_resource(
|
||||||
|
"Price List",
|
||||||
|
&["name"],
|
||||||
|
json!([["enabled", "=", 1], ["selling", "=", 1]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("customerGroups", out.customer_groups, async {
|
load!("customerGroups", out.customer_groups, async {
|
||||||
self.list_resource("Customer Group", &["name"], json!([["is_group", "=", 0]]), "name asc")
|
self.list_resource(
|
||||||
|
"Customer Group",
|
||||||
|
&["name"],
|
||||||
|
json!([["is_group", "=", 0]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("territories", out.territories, async {
|
load!("territories", out.territories, async {
|
||||||
self.list_resource("Territory", &["name"], json!([["is_group", "=", 0]]), "name asc")
|
self.list_resource(
|
||||||
|
"Territory",
|
||||||
|
&["name"],
|
||||||
|
json!([["is_group", "=", 0]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("namingSeries", out.naming_series, async {
|
load!("namingSeries", out.naming_series, async {
|
||||||
self.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Sales Invoice".to_string())])
|
self.get(
|
||||||
|
&["api", "method", "frappe.desk.form.load.getdoctype"],
|
||||||
|
&[("doctype", "Sales Invoice".to_string())],
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map(|v| parse_naming_series(&v))
|
.map(|v| parse_naming_series(&v))
|
||||||
});
|
});
|
||||||
@@ -291,11 +405,32 @@ impl ErpClient {
|
|||||||
["Dynamic Link", "link_name", "=", company],
|
["Dynamic Link", "link_name", "=", company],
|
||||||
["disabled", "=", 0]
|
["disabled", "=", 0]
|
||||||
]);
|
]);
|
||||||
let base = ["name", "address_title", "address_line1", "city", "state", "pincode"];
|
let base = [
|
||||||
let with_gstin = ["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"];
|
"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").
|
// `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 {
|
let rows = match self
|
||||||
Err(e) if e.kind == ErrorKind::Validation => self.list_resource("Address", &base, filters, "name asc").await?,
|
.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?,
|
other => other?,
|
||||||
};
|
};
|
||||||
Ok::<_, ErpError>(
|
Ok::<_, ErpError>(
|
||||||
@@ -304,7 +439,13 @@ impl ErpClient {
|
|||||||
item(
|
item(
|
||||||
r,
|
r,
|
||||||
"address_title",
|
"address_title",
|
||||||
join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]),
|
join(&[
|
||||||
|
text(r, "address_line1"),
|
||||||
|
text(r, "city"),
|
||||||
|
text(r, "state"),
|
||||||
|
text(r, "pincode"),
|
||||||
|
text(r, "gstin"),
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -314,21 +455,39 @@ impl ErpClient {
|
|||||||
self.list_resource(
|
self.list_resource(
|
||||||
"Account",
|
"Account",
|
||||||
&["name", "account_name", "account_type"],
|
&["name", "account_name", "account_type"],
|
||||||
json!([["company", "=", company], ["root_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]),
|
json!([
|
||||||
|
["company", "=", company],
|
||||||
|
["root_type", "=", "Income"],
|
||||||
|
["is_group", "=", 0],
|
||||||
|
["disabled", "=", 0]
|
||||||
|
]),
|
||||||
"name asc",
|
"name asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "account_name", text(r, "account_type").to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("taxAccounts", out.tax_accounts, async {
|
load!("taxAccounts", out.tax_accounts, async {
|
||||||
self.list_resource(
|
self.list_resource(
|
||||||
"Account",
|
"Account",
|
||||||
&["name", "account_name", "account_type"],
|
&["name", "account_name", "account_type"],
|
||||||
json!([["company", "=", company], ["account_type", "=", "Tax"], ["is_group", "=", 0], ["disabled", "=", 0]]),
|
json!([
|
||||||
|
["company", "=", company],
|
||||||
|
["account_type", "=", "Tax"],
|
||||||
|
["is_group", "=", 0],
|
||||||
|
["disabled", "=", 0]
|
||||||
|
]),
|
||||||
"name asc",
|
"name asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "account_name", text(r, "account_type").to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("taxTemplates", out.tax_templates, async {
|
load!("taxTemplates", out.tax_templates, async {
|
||||||
self.list_resource(
|
self.list_resource(
|
||||||
@@ -338,17 +497,29 @@ impl ErpClient {
|
|||||||
"name asc",
|
"name asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "title", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "title", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
load!("costCenters", out.cost_centers, async {
|
load!("costCenters", out.cost_centers, async {
|
||||||
self.list_resource(
|
self.list_resource(
|
||||||
"Cost Center",
|
"Cost Center",
|
||||||
&["name", "cost_center_name"],
|
&["name", "cost_center_name"],
|
||||||
json!([["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]),
|
json!([
|
||||||
|
["company", "=", company],
|
||||||
|
["is_group", "=", 0],
|
||||||
|
["disabled", "=", 0]
|
||||||
|
]),
|
||||||
"name asc",
|
"name asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(|rows| rows.iter().filter_map(|r| item(r, "cost_center_name", String::new())).collect())
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|r| item(r, "cost_center_name", String::new()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
@@ -363,7 +534,10 @@ pub struct Warning {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn warn(code: &str, message: impl Into<String>) -> Warning {
|
fn warn(code: &str, message: impl Into<String>) -> Warning {
|
||||||
Warning { code: code.to_string(), message: message.into() }
|
Warning {
|
||||||
|
code: code.to_string(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Facts from the local database that the warnings need.
|
/// Facts from the local database that the warnings need.
|
||||||
@@ -390,7 +564,9 @@ pub struct RemoteFacts {
|
|||||||
pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
|
pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
|
||||||
let for_company: Vec<&Value> = rows
|
let for_company: Vec<&Value> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output"))
|
.filter(|r| {
|
||||||
|
text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output")
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if for_company.is_empty() {
|
if for_company.is_empty() {
|
||||||
return vec![warn(
|
return vec![warn(
|
||||||
@@ -419,7 +595,11 @@ pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pure: turns the configuration and the facts gathered into the list shown after a connection test.
|
/// Pure: turns the configuration and the facts gathered into the list shown after a connection test.
|
||||||
pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFacts) -> Vec<Warning> {
|
pub fn build_warnings(
|
||||||
|
cfg: &ErpnextConfig,
|
||||||
|
local: &LocalFacts,
|
||||||
|
remote: &RemoteFacts,
|
||||||
|
) -> Vec<Warning> {
|
||||||
let mut w = Vec::new();
|
let mut w = Vec::new();
|
||||||
let features = remote.versions.as_ref().map(feature_switches);
|
let features = remote.versions.as_ref().map(feature_switches);
|
||||||
let ic = features.is_some_and(|f| f.india_compliance);
|
let ic = features.is_some_and(|f| f.india_compliance);
|
||||||
@@ -449,11 +629,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if cfg.naming_series.is_empty() {
|
} else if cfg.naming_series.is_empty() {
|
||||||
w.push(warn("naming_series_missing", "ERPNext series naming is selected but no naming series is chosen."));
|
w.push(warn(
|
||||||
|
"naming_series_missing",
|
||||||
|
"ERPNext series naming is selected but no naming series is chosen.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if local.vendor_registered {
|
if local.vendor_registered {
|
||||||
if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty() {
|
if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty()
|
||||||
|
{
|
||||||
w.push(warn(
|
w.push(warn(
|
||||||
"tax_accounts_missing",
|
"tax_accounts_missing",
|
||||||
"The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.",
|
"The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.",
|
||||||
@@ -479,9 +663,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.company_address.is_empty() {
|
if cfg.company_address.is_empty() {
|
||||||
w.push(warn("company_address_missing", "India Compliance needs a company address on every invoice; none is selected."));
|
w.push(warn(
|
||||||
|
"company_address_missing",
|
||||||
|
"India Compliance needs a company address on every invoice; none is selected.",
|
||||||
|
));
|
||||||
} else if remote.company_address_count == Some(0) {
|
} else if remote.company_address_count == Some(0) {
|
||||||
w.push(warn("company_address_missing", "No address is linked to the company on the site."));
|
w.push(warn(
|
||||||
|
"company_address_missing",
|
||||||
|
"No address is linked to the company on the site.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
match cfg.naming_mode {
|
match cfg.naming_mode {
|
||||||
NamingMode::Mirror => {
|
NamingMode::Mirror => {
|
||||||
@@ -533,14 +723,21 @@ pub struct ConnectionTest {
|
|||||||
|
|
||||||
/// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only
|
/// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only
|
||||||
/// produce warnings.
|
/// produce warnings.
|
||||||
pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &LocalFacts) -> Result<ConnectionTest, ErpError> {
|
pub async fn test_connection(
|
||||||
|
client: &ErpClient,
|
||||||
|
cfg: &ErpnextConfig,
|
||||||
|
local: &LocalFacts,
|
||||||
|
) -> Result<ConnectionTest, ErpError> {
|
||||||
let user = client.get_logged_user().await?;
|
let user = client.get_logged_user().await?;
|
||||||
let versions = match client.get_versions().await {
|
let versions = match client.get_versions().await {
|
||||||
Ok(v) => Some(v),
|
Ok(v) => Some(v),
|
||||||
Err(e) if e.kind == ErrorKind::Auth => return Err(e),
|
Err(e) if e.kind == ErrorKind::Auth => return Err(e),
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
let mut remote = RemoteFacts { versions, ..Default::default() };
|
let mut remote = RemoteFacts {
|
||||||
|
versions,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let features = remote.versions.as_ref().map(feature_switches);
|
let features = remote.versions.as_ref().map(feature_switches);
|
||||||
if features.is_some_and(|f| f.india_compliance) {
|
if features.is_some_and(|f| f.india_compliance) {
|
||||||
if !cfg.company.is_empty() {
|
if !cfg.company.is_empty() {
|
||||||
@@ -548,7 +745,11 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
|
|||||||
.list_resource(
|
.list_resource(
|
||||||
"Address",
|
"Address",
|
||||||
&["name"],
|
&["name"],
|
||||||
json!([["Dynamic Link", "link_doctype", "=", "Company"], ["Dynamic Link", "link_name", "=", cfg.company], ["disabled", "=", 0]]),
|
json!([
|
||||||
|
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||||
|
["Dynamic Link", "link_name", "=", cfg.company],
|
||||||
|
["disabled", "=", 0]
|
||||||
|
]),
|
||||||
"name asc",
|
"name asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -560,7 +761,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
|
|||||||
client
|
client
|
||||||
.get(&["api", "resource", "GST Settings", "GST Settings"], &[])
|
.get(&["api", "resource", "GST Settings", "GST Settings"], &[])
|
||||||
.await
|
.await
|
||||||
.map(|v| v.get("data").and_then(|d| d.get("gst_accounts")).and_then(Value::as_array).cloned().unwrap_or_default())
|
.map(|v| {
|
||||||
|
v.get("data")
|
||||||
|
.and_then(|d| d.get("gst_accounts"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
.map_err(|e| e.to_string()),
|
.map_err(|e| e.to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -568,7 +775,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
|
|||||||
let warnings = build_warnings(cfg, local, &remote);
|
let warnings = build_warnings(cfg, local, &remote);
|
||||||
let versions = remote.versions.unwrap_or_default();
|
let versions = remote.versions.unwrap_or_default();
|
||||||
let features = feature_switches(&versions);
|
let features = feature_switches(&versions);
|
||||||
Ok(ConnectionTest { user, india_compliance: features.india_compliance, features, versions, warnings })
|
Ok(ConnectionTest {
|
||||||
|
user,
|
||||||
|
india_compliance: features.india_compliance,
|
||||||
|
features,
|
||||||
|
versions,
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -608,10 +821,38 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn versions_parse_leniently() {
|
fn versions_parse_leniently() {
|
||||||
assert_eq!(Version::parse("15.73.2"), Some(Version { major: 15, minor: 73, patch: 2 }));
|
assert_eq!(
|
||||||
assert_eq!(Version::parse("v16.0.0-dev"), Some(Version { major: 16, minor: 0, patch: 0 }));
|
Version::parse("15.73.2"),
|
||||||
assert_eq!(Version::parse("15.1"), Some(Version { major: 15, minor: 1, patch: 0 }));
|
Some(Version {
|
||||||
assert_eq!(Version::parse("15.73.0-beta.2"), Some(Version { major: 15, minor: 73, patch: 0 }));
|
major: 15,
|
||||||
|
minor: 73,
|
||||||
|
patch: 2
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Version::parse("v16.0.0-dev"),
|
||||||
|
Some(Version {
|
||||||
|
major: 16,
|
||||||
|
minor: 0,
|
||||||
|
patch: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Version::parse("15.1"),
|
||||||
|
Some(Version {
|
||||||
|
major: 15,
|
||||||
|
minor: 1,
|
||||||
|
patch: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Version::parse("15.73.0-beta.2"),
|
||||||
|
Some(Version {
|
||||||
|
major: 15,
|
||||||
|
minor: 73,
|
||||||
|
patch: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
assert_eq!(Version::parse("unknown"), None);
|
assert_eq!(Version::parse("unknown"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +881,11 @@ mod tests {
|
|||||||
assert_eq!(v.frappe.as_deref(), Some("15.73.0"));
|
assert_eq!(v.frappe.as_deref(), Some("15.73.0"));
|
||||||
assert_eq!(v.erpnext.as_deref(), Some("15.121.6"));
|
assert_eq!(v.erpnext.as_deref(), Some("15.121.6"));
|
||||||
assert_eq!(v.india_compliance.as_deref(), Some("15.9.0"));
|
assert_eq!(v.india_compliance.as_deref(), Some("15.9.0"));
|
||||||
assert!(parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } })).india_compliance.is_none());
|
assert!(
|
||||||
|
parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } }))
|
||||||
|
.india_compliance
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -648,7 +893,16 @@ mod tests {
|
|||||||
for ok in ["INV/2026-001", "A", "1234567890123456", "AP-24-25/0001"] {
|
for ok in ["INV/2026-001", "A", "1234567890123456", "AP-24-25/0001"] {
|
||||||
assert!(ic_number_ok(ok), "{ok}");
|
assert!(ic_number_ok(ok), "{ok}");
|
||||||
}
|
}
|
||||||
for bad in ["", "12345678901234567", "_AB", "AB 01", "AB_01", "-AB", "/AB", "AB#1"] {
|
for bad in [
|
||||||
|
"",
|
||||||
|
"12345678901234567",
|
||||||
|
"_AB",
|
||||||
|
"AB 01",
|
||||||
|
"AB_01",
|
||||||
|
"-AB",
|
||||||
|
"/AB",
|
||||||
|
"AB#1",
|
||||||
|
] {
|
||||||
assert!(!ic_number_ok(bad), "{bad}");
|
assert!(!ic_number_ok(bad), "{bad}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,9 +910,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn naming_series_is_expanded_before_measuring() {
|
fn naming_series_is_expanded_before_measuring() {
|
||||||
assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5);
|
assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5);
|
||||||
assert_eq!(naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"), 9 + 4 + 1 + 5);
|
assert_eq!(
|
||||||
assert_eq!(naming_series_expanded_len("INV-.MM.-.DD.-.####"), 4 + 2 + 1 + 2 + 1 + 4);
|
naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"),
|
||||||
assert_eq!(naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"), 15 + 4 + 1 + 5);
|
9 + 4 + 1 + 5
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
naming_series_expanded_len("INV-.MM.-.DD.-.####"),
|
||||||
|
4 + 2 + 1 + 2 + 1 + 4
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"),
|
||||||
|
15 + 4 + 1 + 5
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -668,33 +931,54 @@ mod tests {
|
|||||||
{ "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" }
|
{ "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" }
|
||||||
]}]});
|
]}]});
|
||||||
let items = parse_naming_series(&resp);
|
let items = parse_naming_series(&resp);
|
||||||
assert_eq!(items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(), ["SINV-.YY.-", "ACC-SINV-.YYYY.-"]);
|
assert_eq!(
|
||||||
|
items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(),
|
||||||
|
["SINV-.YY.-", "ACC-SINV-.YYYY.-"]
|
||||||
|
);
|
||||||
assert!(parse_naming_series(&json!({})).is_empty());
|
assert!(parse_naming_series(&json!({})).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_healthy_ic_setup_has_no_warnings() {
|
fn a_healthy_ic_setup_has_no_warnings() {
|
||||||
let local = LocalFacts { vendor_registered: true, invalid_numbers: vec![], next_number: Some("INV/2026-001".into()) };
|
let local = LocalFacts {
|
||||||
assert_eq!(build_warnings(&cfg(), &local, &ic_versions("15.73.0")), vec![]);
|
vendor_registered: true,
|
||||||
|
invalid_numbers: vec![],
|
||||||
|
next_number: Some("INV/2026-001".into()),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
build_warnings(&cfg(), &local, &ic_versions("15.73.0")),
|
||||||
|
vec![]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gst_accounts_not_configured_or_mismatched_are_flagged() {
|
fn gst_accounts_not_configured_or_mismatched_are_flagged() {
|
||||||
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
let local = LocalFacts {
|
||||||
|
vendor_registered: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let mut remote = ic_versions("15.73.0");
|
let mut remote = ic_versions("15.73.0");
|
||||||
remote.gst_accounts = Some(Ok(vec![]));
|
remote.gst_accounts = Some(Ok(vec![]));
|
||||||
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_accounts_not_configured"]);
|
assert_eq!(
|
||||||
|
codes(&build_warnings(&cfg(), &local, &remote)),
|
||||||
|
["gst_accounts_not_configured"]
|
||||||
|
);
|
||||||
|
|
||||||
let mut remote = ic_versions("15.73.0");
|
let mut remote = ic_versions("15.73.0");
|
||||||
remote.gst_accounts = Some(Ok(vec![json!({ "company": "Test Co", "account_type": "Output",
|
remote.gst_accounts = Some(Ok(vec![
|
||||||
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" })]));
|
json!({ "company": "Test Co", "account_type": "Output",
|
||||||
|
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" }),
|
||||||
|
]));
|
||||||
let w = build_warnings(&cfg(), &local, &remote);
|
let w = build_warnings(&cfg(), &local, &remote);
|
||||||
assert_eq!(codes(&w), ["gst_account_mismatch"]);
|
assert_eq!(codes(&w), ["gst_account_mismatch"]);
|
||||||
assert!(w[0].message.contains("SGST"));
|
assert!(w[0].message.contains("SGST"));
|
||||||
|
|
||||||
let mut remote = ic_versions("15.73.0");
|
let mut remote = ic_versions("15.73.0");
|
||||||
remote.gst_accounts = Some(Err("HTTP 403".into()));
|
remote.gst_accounts = Some(Err("HTTP 403".into()));
|
||||||
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_settings_unreadable"]);
|
assert_eq!(
|
||||||
|
codes(&build_warnings(&cfg(), &local, &remote)),
|
||||||
|
["gst_settings_unreadable"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -727,20 +1011,45 @@ mod tests {
|
|||||||
|
|
||||||
let mut remote = ic_versions("15.73.0");
|
let mut remote = ic_versions("15.73.0");
|
||||||
remote.company_address_count = Some(0);
|
remote.company_address_count = Some(0);
|
||||||
assert_eq!(codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)), ["company_address_missing"]);
|
assert_eq!(
|
||||||
|
codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)),
|
||||||
|
["company_address_missing"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn required_settings_and_unknown_versions_are_flagged() {
|
fn required_settings_and_unknown_versions_are_flagged() {
|
||||||
let w = build_warnings(&ErpnextConfig::default(), &LocalFacts { vendor_registered: true, ..Default::default() }, &RemoteFacts::default());
|
let w = build_warnings(
|
||||||
assert_eq!(codes(&w), ["versions_unknown", "company_missing", "income_account_missing", "tax_accounts_missing"]);
|
&ErpnextConfig::default(),
|
||||||
|
&LocalFacts {
|
||||||
|
vendor_registered: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&RemoteFacts::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
codes(&w),
|
||||||
|
[
|
||||||
|
"versions_unknown",
|
||||||
|
"company_missing",
|
||||||
|
"income_account_missing",
|
||||||
|
"tax_accounts_missing"
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registered_vendor_without_ic_gets_a_notice() {
|
fn registered_vendor_without_ic_gets_a_notice() {
|
||||||
let mut remote = ic_versions("15.73.0");
|
let mut remote = ic_versions("15.73.0");
|
||||||
remote.versions.as_mut().unwrap().india_compliance = None;
|
remote.versions.as_mut().unwrap().india_compliance = None;
|
||||||
let w = build_warnings(&cfg(), &LocalFacts { vendor_registered: true, ..Default::default() }, &remote);
|
let w = build_warnings(
|
||||||
|
&cfg(),
|
||||||
|
&LocalFacts {
|
||||||
|
vendor_registered: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&remote,
|
||||||
|
);
|
||||||
assert_eq!(codes(&w), ["ic_not_detected"]);
|
assert_eq!(codes(&w), ["ic_not_detected"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,27 +1062,51 @@ mod tests {
|
|||||||
if path.contains("get_logged_user") {
|
if path.contains("get_logged_user") {
|
||||||
reply(200, json!({ "message": "integration@example.com" }))
|
reply(200, json!({ "message": "integration@example.com" }))
|
||||||
} else if path.contains("get_versions") {
|
} else if path.contains("get_versions") {
|
||||||
reply(200, json!({ "message": {
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "message": {
|
||||||
"frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" }
|
"frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" }
|
||||||
}}))
|
}}),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") {
|
} else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") {
|
||||||
reply(200, json!({ "data": { "gst_accounts": [] } }))
|
reply(200, json!({ "data": { "gst_accounts": [] } }))
|
||||||
} else if path.starts_with("/api/resource/Address?") {
|
} else if path.starts_with("/api/resource/Address?") {
|
||||||
reply(200, json!({ "data": [
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "data": [
|
||||||
{ "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" }
|
{ "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" }
|
||||||
]}))
|
]}),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/Company?") {
|
} else if path.starts_with("/api/resource/Company?") {
|
||||||
reply(200, json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }))
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/Account?") && path.contains("Income") {
|
} else if path.starts_with("/api/resource/Account?") && path.contains("Income") {
|
||||||
reply(200, json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] }))
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] }),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/Account?") && path.contains("Tax") {
|
} else if path.starts_with("/api/resource/Account?") && path.contains("Tax") {
|
||||||
reply(200, json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] }))
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] }),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/Price%20List?") {
|
} else if path.starts_with("/api/resource/Price%20List?") {
|
||||||
reply(403, json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" }))
|
reply(
|
||||||
|
403,
|
||||||
|
json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" }),
|
||||||
|
)
|
||||||
} else if path.contains("getdoctype") {
|
} else if path.contains("getdoctype") {
|
||||||
reply(200, json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] }))
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] }),
|
||||||
|
)
|
||||||
} else if path.starts_with("/api/resource/UOM?") {
|
} else if path.starts_with("/api/resource/UOM?") {
|
||||||
reply(200, json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] }))
|
reply(
|
||||||
|
200,
|
||||||
|
json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] }),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
reply(200, json!({ "data": [] }))
|
reply(200, json!({ "data": [] }))
|
||||||
}
|
}
|
||||||
@@ -782,8 +1115,14 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn connection_test_reports_user_versions_ic_and_warnings() {
|
async fn connection_test_reports_user_versions_ic_and_warnings() {
|
||||||
let mock = serve_fn(|_, line| site(line)).await;
|
let mock = serve_fn(|_, line| site(line)).await;
|
||||||
let local = LocalFacts { vendor_registered: true, next_number: Some("INV/2026-001".into()), ..Default::default() };
|
let local = LocalFacts {
|
||||||
let result = test_connection(&client(&mock), &cfg(), &local).await.unwrap();
|
vendor_registered: true,
|
||||||
|
next_number: Some("INV/2026-001".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = test_connection(&client(&mock), &cfg(), &local)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(result.user, "integration@example.com");
|
assert_eq!(result.user, "integration@example.com");
|
||||||
assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0"));
|
assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0"));
|
||||||
assert!(result.india_compliance && result.features.v2_naming);
|
assert!(result.india_compliance && result.features.v2_naming);
|
||||||
@@ -810,17 +1149,25 @@ mod tests {
|
|||||||
let options = client(&mock).load_options("Test Co").await.unwrap();
|
let options = client(&mock).load_options("Test Co").await.unwrap();
|
||||||
assert_eq!(options.company_addresses.len(), 1);
|
assert_eq!(options.company_addresses.len(), 1);
|
||||||
assert_eq!(options.company_addresses[0].name, "Test Co-Billing");
|
assert_eq!(options.company_addresses[0].name, "Test Co-Billing");
|
||||||
assert!(options.errors.iter().all(|e| e.list != "companyAddresses"), "{:?}", options.errors);
|
assert!(
|
||||||
|
options.errors.iter().all(|e| e.list != "companyAddresses"),
|
||||||
|
"{:?}",
|
||||||
|
options.errors
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
|
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
|
||||||
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;
|
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;
|
||||||
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err();
|
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Auth);
|
assert_eq!(err.kind, ErrorKind::Auth);
|
||||||
|
|
||||||
let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await;
|
let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await;
|
||||||
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err();
|
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Auth);
|
assert_eq!(err.kind, ErrorKind::Auth);
|
||||||
assert!(!err.to_string().contains("thesecret"));
|
assert!(!err.to_string().contains("thesecret"));
|
||||||
}
|
}
|
||||||
@@ -837,17 +1184,29 @@ mod tests {
|
|||||||
assert_eq!(options.income_accounts[0].name, "Sales - AC");
|
assert_eq!(options.income_accounts[0].name, "Sales - AC");
|
||||||
assert_eq!(options.tax_accounts[0].name, "Output CGST - AC");
|
assert_eq!(options.tax_accounts[0].name, "Output CGST - AC");
|
||||||
assert_eq!(options.naming_series[0].name, "SINV-.YY.-");
|
assert_eq!(options.naming_series[0].name, "SINV-.YY.-");
|
||||||
assert_eq!(options.uoms.iter().map(|u| u.name.as_str()).collect::<Vec<_>>(), ["Nos", "Hour"]);
|
assert_eq!(
|
||||||
|
options
|
||||||
|
.uoms
|
||||||
|
.iter()
|
||||||
|
.map(|u| u.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["Nos", "Hour"]
|
||||||
|
);
|
||||||
assert!(options.price_lists.is_empty());
|
assert!(options.price_lists.is_empty());
|
||||||
assert_eq!(options.errors.len(), 1);
|
assert_eq!(options.errors.len(), 1);
|
||||||
assert_eq!(options.errors[0].list, "priceLists");
|
assert_eq!(options.errors[0].list, "priceLists");
|
||||||
assert!(options.errors[0].message.contains("No permission for Price List"));
|
assert!(options.errors[0]
|
||||||
|
.message
|
||||||
|
.contains("No permission for Price List"));
|
||||||
|
|
||||||
// Company-scoped lists are skipped until a company is chosen.
|
// Company-scoped lists are skipped until a company is chosen.
|
||||||
let none = client(&mock).load_options(" ").await.unwrap();
|
let none = client(&mock).load_options(" ").await.unwrap();
|
||||||
assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty());
|
assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty());
|
||||||
let requests = mock.requests.lock().unwrap().join("\n");
|
let requests = mock.requests.lock().unwrap().join("\n");
|
||||||
assert!(requests.contains("root_type"), "income accounts are filtered by root type");
|
assert!(
|
||||||
|
requests.contains("root_type"),
|
||||||
|
"income accounts are filtered by root type"
|
||||||
|
);
|
||||||
assert!(requests.contains("limit_page_length=0"));
|
assert!(requests.contains("limit_page_length=0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,6 +1215,10 @@ mod tests {
|
|||||||
let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await;
|
let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await;
|
||||||
let err = client(&mock).load_options("Test Co").await.unwrap_err();
|
let err = client(&mock).load_options("Test Co").await.unwrap_err();
|
||||||
assert_eq!(err.kind, ErrorKind::Auth);
|
assert_eq!(err.kind, ErrorKind::Auth);
|
||||||
assert_eq!(mock.hits.load(std::sync::atomic::Ordering::SeqCst), 1, "stops at the first auth failure");
|
assert_eq!(
|
||||||
|
mock.hits.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"stops at the first auth failure"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,12 @@ impl From<ErpError> for String {
|
|||||||
|
|
||||||
impl ErpError {
|
impl ErpError {
|
||||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||||
ErpError { kind, status: None, message: message.into(), exc_type: None }
|
ErpError {
|
||||||
|
kind,
|
||||||
|
status: None,
|
||||||
|
message: message.into(),
|
||||||
|
exc_type: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config(message: impl Into<String>) -> Self {
|
pub fn config(message: impl Into<String>) -> Self {
|
||||||
@@ -99,8 +104,15 @@ impl ErpError {
|
|||||||
if parsed.exc_type.as_deref() == Some("DuplicateEntryError") {
|
if parsed.exc_type.as_deref() == Some("DuplicateEntryError") {
|
||||||
kind = ErrorKind::Duplicate;
|
kind = ErrorKind::Duplicate;
|
||||||
}
|
}
|
||||||
let message = parsed.message.unwrap_or_else(|| default_status_text(status));
|
let message = parsed
|
||||||
ErpError { kind, status: Some(status), message, exc_type: parsed.exc_type }
|
.message
|
||||||
|
.unwrap_or_else(|| default_status_text(status));
|
||||||
|
ErpError {
|
||||||
|
kind,
|
||||||
|
status: Some(status),
|
||||||
|
message,
|
||||||
|
exc_type: parsed.exc_type,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes every occurrence of the given secrets from the message.
|
/// Removes every occurrence of the given secrets from the message.
|
||||||
@@ -150,14 +162,24 @@ const MAX_MESSAGE_CHARS: usize = 600;
|
|||||||
pub fn parse_error_body(body: &str) -> ParsedError {
|
pub fn parse_error_body(body: &str) -> ParsedError {
|
||||||
let Ok(json) = serde_json::from_str::<Value>(body) else {
|
let Ok(json) = serde_json::from_str::<Value>(body) else {
|
||||||
let text = strip_html(body);
|
let text = strip_html(body);
|
||||||
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim();
|
let first = text
|
||||||
return ParsedError { message: non_empty(truncate(first)), exc_type: None };
|
.lines()
|
||||||
|
.find(|l| !l.trim().is_empty())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
return ParsedError {
|
||||||
|
message: non_empty(truncate(first)),
|
||||||
|
exc_type: None,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
let Some(obj) = json.as_object() else {
|
let Some(obj) = json.as_object() else {
|
||||||
return ParsedError::default();
|
return ParsedError::default();
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut exc_type = obj.get("exc_type").and_then(Value::as_str).map(str::to_string);
|
let mut exc_type = obj
|
||||||
|
.get("exc_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
let mut messages: Vec<String> = Vec::new();
|
let mut messages: Vec<String> = Vec::new();
|
||||||
|
|
||||||
if let Some(errors) = obj.get("errors").and_then(Value::as_array) {
|
if let Some(errors) = obj.get("errors").and_then(Value::as_array) {
|
||||||
@@ -165,8 +187,15 @@ pub fn parse_error_body(body: &str) -> ParsedError {
|
|||||||
match e {
|
match e {
|
||||||
Value::String(s) => push_unique(&mut messages, strip_html(s)),
|
Value::String(s) => push_unique(&mut messages, strip_html(s)),
|
||||||
Value::Object(o) => {
|
Value::Object(o) => {
|
||||||
let text = o.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty());
|
let text = o
|
||||||
let exception = o.get("exception").and_then(Value::as_str).map(split_exception);
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(strip_html)
|
||||||
|
.filter(|m| !m.is_empty());
|
||||||
|
let exception = o
|
||||||
|
.get("exception")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(split_exception);
|
||||||
if exc_type.is_none() {
|
if exc_type.is_none() {
|
||||||
exc_type = o
|
exc_type = o
|
||||||
.get("type")
|
.get("type")
|
||||||
@@ -174,7 +203,9 @@ pub fn parse_error_body(body: &str) -> ParsedError {
|
|||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.or_else(|| exception.as_ref().and_then(|(t, _)| t.clone()));
|
.or_else(|| exception.as_ref().and_then(|(t, _)| t.clone()));
|
||||||
}
|
}
|
||||||
if let Some(m) = text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty())) {
|
if let Some(m) =
|
||||||
|
text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty()))
|
||||||
|
{
|
||||||
push_unique(&mut messages, m);
|
push_unique(&mut messages, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +222,12 @@ pub fn parse_error_body(body: &str) -> ParsedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
if let Some(m) = obj.get("_error_message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) {
|
if let Some(m) = obj
|
||||||
|
.get("_error_message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(strip_html)
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
{
|
||||||
messages.push(m);
|
messages.push(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +243,12 @@ pub fn parse_error_body(body: &str) -> ParsedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
if let Some(m) = obj.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) {
|
if let Some(m) = obj
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(strip_html)
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
{
|
||||||
messages.push(m);
|
messages.push(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,10 +259,16 @@ pub fn parse_error_body(body: &str) -> ParsedError {
|
|||||||
}
|
}
|
||||||
if exc_type.is_none() {
|
if exc_type.is_none() {
|
||||||
// v1 on Frappe 14 has no `exc_type`; the class is still named in `exception`.
|
// v1 on Frappe 14 has no `exc_type`; the class is still named in `exception`.
|
||||||
exc_type = obj.get("exception").and_then(Value::as_str).and_then(|e| split_exception(e).0);
|
exc_type = obj
|
||||||
|
.get("exception")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|e| split_exception(e).0);
|
||||||
}
|
}
|
||||||
|
|
||||||
ParsedError { message: non_empty(truncate(&messages.join("; "))), exc_type }
|
ParsedError {
|
||||||
|
message: non_empty(truncate(&messages.join("; "))),
|
||||||
|
exc_type,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn server_messages(raw: &str) -> Vec<String> {
|
fn server_messages(raw: &str) -> Vec<String> {
|
||||||
@@ -233,10 +280,18 @@ fn server_messages(raw: &str) -> Vec<String> {
|
|||||||
.filter_map(|item| {
|
.filter_map(|item| {
|
||||||
let text = match item {
|
let text = match item {
|
||||||
Value::String(s) => match serde_json::from_str::<Value>(s) {
|
Value::String(s) => match serde_json::from_str::<Value>(s) {
|
||||||
Ok(Value::Object(o)) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
|
Ok(Value::Object(o)) => o
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
_ => s.clone(),
|
_ => s.clone(),
|
||||||
},
|
},
|
||||||
Value::Object(o) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
|
Value::Object(o) => o
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
};
|
};
|
||||||
let text = strip_html(&text);
|
let text = strip_html(&text);
|
||||||
@@ -250,18 +305,28 @@ fn server_messages(raw: &str) -> Vec<String> {
|
|||||||
fn split_exception(raw: &str) -> (Option<String>, String) {
|
fn split_exception(raw: &str) -> (Option<String>, String) {
|
||||||
let first_line = raw.trim().lines().next().unwrap_or("").trim();
|
let first_line = raw.trim().lines().next().unwrap_or("").trim();
|
||||||
if let Some((prefix, rest)) = first_line.split_once(": ") {
|
if let Some((prefix, rest)) = first_line.split_once(": ") {
|
||||||
let class_like = prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception");
|
let class_like =
|
||||||
if class_like && prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') {
|
prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception");
|
||||||
|
if class_like
|
||||||
|
&& prefix
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||||
|
{
|
||||||
let ty = prefix.rsplit('.').next().map(str::to_string);
|
let ty = prefix.rsplit('.').next().map(str::to_string);
|
||||||
return (ty, strip_html(rest));
|
return (ty, strip_html(rest));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !first_line.is_empty()
|
if !first_line.is_empty()
|
||||||
&& first_line.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
&& first_line
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||||
&& first_line.contains('.')
|
&& first_line.contains('.')
|
||||||
{
|
{
|
||||||
// `frappe.exceptions.DoesNotExistError` with no message.
|
// `frappe.exceptions.DoesNotExistError` with no message.
|
||||||
return (first_line.rsplit('.').next().map(str::to_string), String::new());
|
return (
|
||||||
|
first_line.rsplit('.').next().map(str::to_string),
|
||||||
|
String::new(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
(None, strip_html(first_line))
|
(None, strip_html(first_line))
|
||||||
}
|
}
|
||||||
@@ -297,8 +362,16 @@ pub fn strip_html(input: &str) -> String {
|
|||||||
}
|
}
|
||||||
'>' if in_tag => {
|
'>' if in_tag => {
|
||||||
in_tag = false;
|
in_tag = false;
|
||||||
let name = tag.trim_start_matches('/').split_whitespace().next().unwrap_or("").to_ascii_lowercase();
|
let name = tag
|
||||||
if matches!(name.as_str(), "br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3") {
|
.trim_start_matches('/')
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3"
|
||||||
|
) {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +412,10 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let parsed = parse_error_body(&body);
|
let parsed = parse_error_body(&body);
|
||||||
assert_eq!(parsed.message.as_deref(), Some("Row 1: Account Sales & more; Second\nproblem"));
|
assert_eq!(
|
||||||
|
parsed.message.as_deref(),
|
||||||
|
Some("Row 1: Account Sales & more; Second\nproblem")
|
||||||
|
);
|
||||||
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
|
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
|
||||||
let err = ErpError::from_response(417, &body);
|
let err = ErpError::from_response(417, &body);
|
||||||
assert_eq!(err.kind, ErrorKind::Validation);
|
assert_eq!(err.kind, ErrorKind::Validation);
|
||||||
@@ -354,7 +430,10 @@ mod tests {
|
|||||||
let b = parse_error_body(
|
let b = parse_error_body(
|
||||||
&json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(),
|
&json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(),
|
||||||
);
|
);
|
||||||
assert_eq!(b.message.as_deref(), Some("Sales Invoice INV/1 already exists"));
|
assert_eq!(
|
||||||
|
b.message.as_deref(),
|
||||||
|
Some("Sales Invoice INV/1 already exists")
|
||||||
|
);
|
||||||
assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError"));
|
assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError"));
|
||||||
|
|
||||||
let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string());
|
let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string());
|
||||||
@@ -363,8 +442,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exception_prefix_is_only_dropped_when_it_looks_like_a_class_path() {
|
fn exception_prefix_is_only_dropped_when_it_looks_like_a_class_path() {
|
||||||
let parsed = parse_error_body(&json!({ "exception": "Something: with a colon but no class" }).to_string());
|
let parsed = parse_error_body(
|
||||||
assert_eq!(parsed.message.as_deref(), Some("Something: with a colon but no class"));
|
&json!({ "exception": "Something: with a colon but no class" }).to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.message.as_deref(),
|
||||||
|
Some("Something: with a colon but no class")
|
||||||
|
);
|
||||||
assert_eq!(parsed.exc_type, None);
|
assert_eq!(parsed.exc_type, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +462,10 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let parsed = parse_error_body(&body);
|
let parsed = parse_error_body(&body);
|
||||||
assert_eq!(parsed.message.as_deref(), Some("Customer X is disabled; Row 2: rate missing"));
|
assert_eq!(
|
||||||
|
parsed.message.as_deref(),
|
||||||
|
Some("Customer X is disabled; Row 2: rate missing")
|
||||||
|
);
|
||||||
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
|
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,22 +493,36 @@ mod tests {
|
|||||||
(502, ErrorKind::Server),
|
(502, ErrorKind::Server),
|
||||||
(418, ErrorKind::Other),
|
(418, ErrorKind::Other),
|
||||||
] {
|
] {
|
||||||
assert_eq!(ErpError::from_response(status, "").kind, kind, "status {status}");
|
assert_eq!(
|
||||||
|
ErpError::from_response(status, "").kind,
|
||||||
|
kind,
|
||||||
|
"status {status}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// The exception class wins when the status is generic.
|
// The exception class wins when the status is generic.
|
||||||
let dup = ErpError::from_response(417, &json!({ "exc_type": "DuplicateEntryError" }).to_string());
|
let dup = ErpError::from_response(
|
||||||
|
417,
|
||||||
|
&json!({ "exc_type": "DuplicateEntryError" }).to_string(),
|
||||||
|
);
|
||||||
assert_eq!(dup.kind, ErrorKind::Duplicate);
|
assert_eq!(dup.kind, ErrorKind::Duplicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn secrets_are_redacted_from_messages() {
|
fn secrets_are_redacted_from_messages() {
|
||||||
let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header").redacted(&["topsecret", "abc123"]);
|
let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header")
|
||||||
|
.redacted(&["topsecret", "abc123"]);
|
||||||
assert_eq!(err.message, "bad token [redacted]:[redacted] in header");
|
assert_eq!(err.message, "bad token [redacted]:[redacted] in header");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_carries_the_status_and_hint() {
|
fn display_carries_the_status_and_hint() {
|
||||||
let err = ErpError::from_response(401, &json!({ "message": "Invalid credentials" }).to_string());
|
let err = ErpError::from_response(
|
||||||
assert_eq!(err.to_string(), "Authentication failed (HTTP 401): Invalid credentials");
|
401,
|
||||||
|
&json!({ "message": "Invalid credentials" }).to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"Authentication failed (HTTP 401): Invalid credentials"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
|
|
||||||
use super::client::{ClientOptions, ErpClient};
|
use super::client::{ClientOptions, ErpClient};
|
||||||
use super::config::{self, ErpnextConfig, NamingMode, Secret};
|
use super::config::{self, ErpnextConfig, NamingMode, Secret};
|
||||||
use super::errors::ErrorKind;
|
|
||||||
use super::discovery::{test_connection, ConnectionTest, LocalFacts};
|
use super::discovery::{test_connection, ConnectionTest, LocalFacts};
|
||||||
|
use super::errors::ErrorKind;
|
||||||
use super::push::{push_invoice, push_payment, PushResult};
|
use super::push::{push_invoice, push_payment, PushResult};
|
||||||
use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl};
|
use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl};
|
||||||
use crate::commands::payments::{record_payment_impl, PaymentInput};
|
use crate::commands::payments::{record_payment_impl, PaymentInput};
|
||||||
@@ -78,7 +78,14 @@ impl Live {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
edit(&mut cfg);
|
edit(&mut cfg);
|
||||||
let http = ErpClient::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, "", ClientOptions::default()).unwrap();
|
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();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -88,8 +95,24 @@ impl Live {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for (name, gstin, state, line1, city, pin, category) in [
|
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"),
|
"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(
|
conn.execute(
|
||||||
"INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at)
|
"INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at)
|
||||||
@@ -98,12 +121,26 @@ impl Live {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() as u64;
|
let nanos = std::time::SystemTime::now()
|
||||||
let prefix = format!("L{}{}/", base36(nanos / 1_000_000, 4), base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1));
|
.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();
|
start_new_series_impl(&mut conn, &prefix, 3).unwrap();
|
||||||
config::save(&conn, &cfg).unwrap();
|
config::save(&conn, &cfg).unwrap();
|
||||||
|
|
||||||
let live = Live { db: Mutex::new(conn), dir: tempfile::tempdir().unwrap(), http, abbr, ic, prefix };
|
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.
|
// The same step the UI performs before the first push.
|
||||||
let ct = live.connection_test(&cfg).await;
|
let ct = live.connection_test(&cfg).await;
|
||||||
let mut cfg = cfg;
|
let mut cfg = cfg;
|
||||||
@@ -113,8 +150,13 @@ impl Live {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest {
|
async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest {
|
||||||
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
let local = LocalFacts {
|
||||||
test_connection(&self.http, cfg, &local).await.expect("connection test")
|
vendor_registered: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
test_connection(&self.http, cfg, &local)
|
||||||
|
.await
|
||||||
|
.expect("connection test")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cfg(&self) -> ErpnextConfig {
|
fn cfg(&self) -> ErpnextConfig {
|
||||||
@@ -176,7 +218,10 @@ impl Live {
|
|||||||
self.db
|
self.db
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.execute("UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", params![sha, inv.id])
|
.execute(
|
||||||
|
"UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2",
|
||||||
|
params![sha, inv.id],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,13 +230,22 @@ impl Live {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn doc(&self, doctype: &str, name: &str) -> Value {
|
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}"));
|
let v = self
|
||||||
|
.http
|
||||||
|
.get(&["api", "resource", doctype, name], &[])
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}"));
|
||||||
v["data"].clone()
|
v["data"].clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn invoices_named(&self, number: &str) -> Vec<Value> {
|
async fn invoices_named(&self, number: &str) -> Vec<Value> {
|
||||||
self.http
|
self.http
|
||||||
.list_resource("Sales Invoice", &["name", "docstatus", "grand_total", "remarks"], json!([["remarks", "like", format!("%{number}%")]]), "creation asc")
|
.list_resource(
|
||||||
|
"Sales Invoice",
|
||||||
|
&["name", "docstatus", "grand_total", "remarks"],
|
||||||
|
json!([["remarks", "like", format!("%{number}%")]]),
|
||||||
|
"creation asc",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
@@ -201,7 +255,10 @@ impl Live {
|
|||||||
.list_resource(
|
.list_resource(
|
||||||
"File",
|
"File",
|
||||||
&["name", "file_name", "is_private", "file_url"],
|
&["name", "file_name", "is_private", "file_url"],
|
||||||
json!([["attached_to_doctype", "=", doctype], ["attached_to_name", "=", name]]),
|
json!([
|
||||||
|
["attached_to_doctype", "=", doctype],
|
||||||
|
["attached_to_name", "=", name]
|
||||||
|
]),
|
||||||
"creation asc",
|
"creation asc",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -248,7 +305,13 @@ fn minimal_pdf(label: &str) -> Vec<u8> {
|
|||||||
for o in offsets {
|
for o in offsets {
|
||||||
out.extend(format!("{o:010} 00000 n \n").into_bytes());
|
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.extend(
|
||||||
|
format!(
|
||||||
|
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
|
||||||
|
objects.len() + 1
|
||||||
|
)
|
||||||
|
.into_bytes(),
|
||||||
|
);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,8 +326,17 @@ fn assert_pushed(r: &PushResult) {
|
|||||||
|
|
||||||
/// The invoice as ERPNext holds it: total equals Voiced's, tax rows are what Voiced computed.
|
/// 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) {
|
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!(
|
||||||
assert_eq!(doc["rounded_total"].as_f64().unwrap_or(0.0), 0.0, "rounding is disabled");
|
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());
|
assert_eq!(doc["posting_date"], inv.invoice_date.as_str());
|
||||||
let taxes = doc["taxes"].as_array().unwrap();
|
let taxes = doc["taxes"].as_array().unwrap();
|
||||||
let by_head = |needle: &str| -> Option<i64> {
|
let by_head = |needle: &str| -> Option<i64> {
|
||||||
@@ -273,10 +345,25 @@ async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
|
|||||||
.find(|t| t["account_head"].as_str().unwrap_or("").contains(needle))
|
.find(|t| t["account_head"].as_str().unwrap_or("").contains(needle))
|
||||||
.map(|t| paise(&t["tax_amount"]))
|
.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);
|
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.
|
// 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!(
|
||||||
assert_eq!(by_head("IGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.igst_amount), "IGST of {}", inv.number);
|
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;
|
let _ = live;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +377,10 @@ async fn live_connection_and_options() {
|
|||||||
let ct = live.connection_test(&cfg).await;
|
let ct = live.connection_test(&cfg).await;
|
||||||
println!("{}", serde_json::to_string_pretty(&ct).unwrap());
|
println!("{}", serde_json::to_string_pretty(&ct).unwrap());
|
||||||
assert!(ct.user.contains('@'));
|
assert!(ct.user.contains('@'));
|
||||||
assert_eq!(ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")), Some(true));
|
assert_eq!(
|
||||||
|
ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
assert!(ct.versions.erpnext.is_some());
|
assert!(ct.versions.erpnext.is_some());
|
||||||
assert_eq!(ct.india_compliance, live.ic);
|
assert_eq!(ct.india_compliance, live.ic);
|
||||||
assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming");
|
assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming");
|
||||||
@@ -298,14 +388,25 @@ async fn live_connection_and_options() {
|
|||||||
let o = live.http.load_options(&cfg.company).await.unwrap();
|
let o = live.http.load_options(&cfg.company).await.unwrap();
|
||||||
println!("option errors: {:?}", o.errors);
|
println!("option errors: {:?}", o.errors);
|
||||||
assert!(o.errors.is_empty(), "{:?}", o.errors);
|
assert!(o.errors.is_empty(), "{:?}", o.errors);
|
||||||
let has = |list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
|
let has =
|
||||||
|
|list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
|
||||||
assert!(has(&o.companies, &cfg.company));
|
assert!(has(&o.companies, &cfg.company));
|
||||||
assert!(has(&o.income_accounts, &cfg.income_account), "{:?}", o.income_accounts);
|
assert!(
|
||||||
assert!(o.tax_accounts.iter().any(|a| a.name.contains("CGST")), "{:?}", o.tax_accounts);
|
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!(has(&o.uoms, "Hour") && has(&o.uoms, "Nos"));
|
||||||
assert!(!o.company_addresses.is_empty());
|
assert!(!o.company_addresses.is_empty());
|
||||||
assert!(!o.naming_series.is_empty());
|
assert!(!o.naming_series.is_empty());
|
||||||
assert!(!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty());
|
assert!(
|
||||||
|
!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Sales Invoice mapping ----
|
// ---- Sales Invoice mapping ----
|
||||||
@@ -361,10 +462,16 @@ async fn live_push_matrix() {
|
|||||||
for (label, inv) in &cases {
|
for (label, inv) in &cases {
|
||||||
live.archive(inv);
|
live.archive(inv);
|
||||||
let r = live.push(inv, None).await;
|
let r = live.push(inv, None).await;
|
||||||
println!("{label}: {} -> {} (warnings {:?})", inv.number, r.remote_name, r.warnings);
|
println!(
|
||||||
|
"{label}: {} -> {} (warnings {:?})",
|
||||||
|
inv.number, r.remote_name, r.warnings
|
||||||
|
);
|
||||||
assert_pushed(&r);
|
assert_pushed(&r);
|
||||||
assert!(r.created && r.attached, "{label}: {r:?}");
|
assert!(r.created && r.attached, "{label}: {r:?}");
|
||||||
assert_eq!(r.remote_name, inv.number, "mirror naming keeps the Voiced number");
|
assert_eq!(
|
||||||
|
r.remote_name, inv.number,
|
||||||
|
"mirror naming keeps the Voiced number"
|
||||||
|
);
|
||||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
assert_eq!(doc["docstatus"], 0);
|
assert_eq!(doc["docstatus"], 0);
|
||||||
assert_matches(&live, inv, &doc).await;
|
assert_matches(&live, inv, &doc).await;
|
||||||
@@ -376,11 +483,18 @@ async fn live_push_matrix() {
|
|||||||
// Customer and address shape, as ERPNext stored them.
|
// Customer and address shape, as ERPNext stored them.
|
||||||
let inter = &cases[1].1;
|
let inter = &cases[1].1;
|
||||||
let doc = live.doc("Sales Invoice", &inter.number).await;
|
let doc = live.doc("Sales Invoice", &inter.number).await;
|
||||||
let customer = live.doc("Customer", doc["customer"].as_str().unwrap()).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_name"], "Voiced Live Inter Co");
|
||||||
assert_eq!(customer["customer_type"], "Company");
|
assert_eq!(customer["customer_type"], "Company");
|
||||||
let address = live.doc("Address", doc["customer_address"].as_str().unwrap()).await;
|
let address = live
|
||||||
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"]}));
|
.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["state"], "Karnataka");
|
||||||
assert_eq!(address["country"], "India");
|
assert_eq!(address["country"], "India");
|
||||||
assert_eq!(address["address_type"], "Billing");
|
assert_eq!(address["address_type"], "Billing");
|
||||||
@@ -393,10 +507,17 @@ async fn live_push_matrix() {
|
|||||||
}
|
}
|
||||||
// A fresh local database (lost link) reuses the customer's address instead of creating another.
|
// A fresh local database (lost link) reuses the customer's address instead of creating another.
|
||||||
let again = Live::new(|_| {}).await;
|
let again = Live::new(|_| {}).await;
|
||||||
let inv = again.issue(false, json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
|
let inv = again.issue(
|
||||||
|
false,
|
||||||
|
json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
assert_pushed(&again.push(&inv, None).await);
|
assert_pushed(&again.push(&inv, None).await);
|
||||||
let d2 = again.doc("Sales Invoice", &inv.number).await;
|
let d2 = again.doc("Sales Invoice", &inv.number).await;
|
||||||
assert_eq!(d2["customer_address"], doc["customer_address"], "address reused");
|
assert_eq!(
|
||||||
|
d2["customer_address"], doc["customer_address"],
|
||||||
|
"address reused"
|
||||||
|
);
|
||||||
assert_eq!(d2["customer"], doc["customer"]);
|
assert_eq!(d2["customer"], doc["customer"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +529,13 @@ async fn live_half_paise_rounding() {
|
|||||||
// (taxable amount, discount): 9% of 10.50 is 0.945, 18% of 0.25 is 0.045 and so on. Voiced rounds half up.
|
// (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
|
// 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.
|
// 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)];
|
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 {
|
for (amount, discount, tie) in ties {
|
||||||
let inv = live.issue(
|
let inv = live.issue(
|
||||||
true,
|
true,
|
||||||
@@ -416,16 +543,31 @@ async fn live_half_paise_rounding() {
|
|||||||
json!({"discount": discount}),
|
json!({"discount": discount}),
|
||||||
);
|
);
|
||||||
let r = live.push(&inv, Some(true)).await;
|
let r = live.push(&inv, Some(true)).await;
|
||||||
println!("{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}", r.ok, r.error);
|
println!(
|
||||||
|
"{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}",
|
||||||
|
r.ok, r.error
|
||||||
|
);
|
||||||
if tie && banker {
|
if tie && banker {
|
||||||
// ERPNext's Banker's Rounding disagrees by a paise: the draft is reported, kept, and never submitted.
|
// 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:?}");
|
assert!(
|
||||||
|
!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
let msg = r.error.as_deref().unwrap();
|
let msg = r.error.as_deref().unwrap();
|
||||||
assert!(msg.contains("Commercial Rounding") && msg.contains("does not submit"), "{msg}");
|
assert!(
|
||||||
|
msg.contains("Commercial Rounding") && msg.contains("does not submit"),
|
||||||
|
"{msg}"
|
||||||
|
);
|
||||||
assert_eq!(r.remote_name, inv.number);
|
assert_eq!(r.remote_name, inv.number);
|
||||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
assert_eq!(doc["docstatus"], 0, "a mismatching document is never submitted");
|
assert_eq!(
|
||||||
assert!(live.files_of("Sales Invoice", &r.remote_name).await.is_empty());
|
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.
|
// Pushing again finds the same draft and reports the same conflict instead of creating another.
|
||||||
let again = live.push(&inv, Some(true)).await;
|
let again = live.push(&inv, Some(true)).await;
|
||||||
assert!(!again.ok && again.status == "conflict", "{again:?}");
|
assert!(!again.ok && again.status == "conflict", "{again:?}");
|
||||||
@@ -442,7 +584,11 @@ async fn live_half_paise_rounding() {
|
|||||||
#[ignore = "needs the live ERPNext stack"]
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
async fn live_repush_is_idempotent() {
|
async fn live_repush_is_idempotent() {
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let inv = live.issue(true, json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]), json!({}));
|
let inv = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
live.archive(&inv);
|
live.archive(&inv);
|
||||||
let first = live.push(&inv, None).await;
|
let first = live.push(&inv, None).await;
|
||||||
assert_pushed(&first);
|
assert_pushed(&first);
|
||||||
@@ -452,7 +598,11 @@ async fn live_repush_is_idempotent() {
|
|||||||
assert_pushed(&again);
|
assert_pushed(&again);
|
||||||
assert!(again.no_op && !again.created, "{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.
|
// 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();
|
live.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute("DELETE FROM erpnext_sync", [])
|
||||||
|
.unwrap();
|
||||||
let adopted = live.push(&inv, None).await;
|
let adopted = live.push(&inv, None).await;
|
||||||
assert_pushed(&adopted);
|
assert_pushed(&adopted);
|
||||||
assert!(adopted.attached, "{adopted:?}");
|
assert!(adopted.attached, "{adopted:?}");
|
||||||
@@ -460,7 +610,12 @@ async fn live_repush_is_idempotent() {
|
|||||||
assert_eq!(adopted.remote_name, first.remote_name);
|
assert_eq!(adopted.remote_name, first.remote_name);
|
||||||
let all = live
|
let all = live
|
||||||
.http
|
.http
|
||||||
.list_resource("Sales Invoice", &["name"], json!([["name", "=", inv.number.clone()]]), "name asc")
|
.list_resource(
|
||||||
|
"Sales Invoice",
|
||||||
|
&["name"],
|
||||||
|
json!([["name", "=", inv.number.clone()]]),
|
||||||
|
"name asc",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(all.len(), 1);
|
assert_eq!(all.len(), 1);
|
||||||
@@ -475,13 +630,24 @@ async fn live_repush_is_idempotent() {
|
|||||||
async fn live_submit_paths() {
|
async fn live_submit_paths() {
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
// Submit while creating.
|
// Submit while creating.
|
||||||
let a = live.issue(true, json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
|
let a = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
let r = live.push(&a, Some(true)).await;
|
let r = live.push(&a, Some(true)).await;
|
||||||
assert_pushed(&r);
|
assert_pushed(&r);
|
||||||
assert_eq!(r.remote_docstatus, 1);
|
assert_eq!(r.remote_docstatus, 1);
|
||||||
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["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).
|
// 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!({}));
|
let b = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
assert_pushed(&live.push(&b, Some(false)).await);
|
assert_pushed(&live.push(&b, Some(false)).await);
|
||||||
let r = live.push(&b, Some(true)).await;
|
let r = live.push(&b, Some(true)).await;
|
||||||
assert_pushed(&r);
|
assert_pushed(&r);
|
||||||
@@ -494,20 +660,43 @@ async fn live_submit_paths() {
|
|||||||
async fn live_series_naming_mode() {
|
async fn live_series_naming_mode() {
|
||||||
let live = Live::new(|c| c.naming_mode = NamingMode::Series).await;
|
let live = Live::new(|c| c.naming_mode = NamingMode::Series).await;
|
||||||
let o = live.http.load_options(&live.cfg().company).await.unwrap();
|
let o = live.http.load_options(&live.cfg().company).await.unwrap();
|
||||||
let series = o.naming_series.first().expect("a naming series").name.clone();
|
let series = o
|
||||||
|
.naming_series
|
||||||
|
.first()
|
||||||
|
.expect("a naming series")
|
||||||
|
.name
|
||||||
|
.clone();
|
||||||
println!("series options: {:?}", o.naming_series);
|
println!("series options: {:?}", o.naming_series);
|
||||||
live.save_cfg(|c| c.naming_series = series.clone());
|
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"}));
|
let inv = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]),
|
||||||
|
json!({"notes": "note"}),
|
||||||
|
);
|
||||||
live.archive(&inv);
|
live.archive(&inv);
|
||||||
let r = live.push(&inv, Some(false)).await;
|
let r = live.push(&inv, Some(false)).await;
|
||||||
assert_pushed(&r);
|
assert_pushed(&r);
|
||||||
assert!(r.created);
|
assert!(r.created);
|
||||||
assert_ne!(r.remote_name, inv.number, "series mode lets ERPNext name it");
|
assert_ne!(
|
||||||
|
r.remote_name, inv.number,
|
||||||
|
"series mode lets ERPNext name it"
|
||||||
|
);
|
||||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
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!(
|
||||||
|
doc["remarks"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with(&format!("Voiced invoice {}", inv.number)),
|
||||||
|
"{}",
|
||||||
|
doc["remarks"]
|
||||||
|
);
|
||||||
assert_matches(&live, &inv, &doc).await;
|
assert_matches(&live, &inv, &doc).await;
|
||||||
// Lose the sync row: the remarks lookup finds the document instead of creating another.
|
// Lose the sync row: the remarks lookup finds the document instead of creating another.
|
||||||
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
|
live.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute("DELETE FROM erpnext_sync", [])
|
||||||
|
.unwrap();
|
||||||
let again = live.push(&inv, Some(false)).await;
|
let again = live.push(&inv, Some(false)).await;
|
||||||
assert_pushed(&again);
|
assert_pushed(&again);
|
||||||
assert!(!again.created, "{again:?}");
|
assert!(!again.created, "{again:?}");
|
||||||
@@ -517,7 +706,10 @@ async fn live_series_naming_mode() {
|
|||||||
let s = live.push(&inv, Some(true)).await;
|
let s = live.push(&inv, Some(true)).await;
|
||||||
assert_pushed(&s);
|
assert_pushed(&s);
|
||||||
assert_eq!(s.remote_docstatus, 1);
|
assert_eq!(s.remote_docstatus, 1);
|
||||||
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
|
assert_eq!(
|
||||||
|
live.doc("Sales Invoice", &r.remote_name).await["docstatus"],
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -529,18 +721,62 @@ async fn live_uom_and_quantities() {
|
|||||||
let row = |desc: &str, unit: &str, qty: f64| json!([{"description": desc, "mode": "rate", "rate": 100.0, "unit": unit, "quantity": qty, "hsnSac": "998314"}]);
|
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();
|
let mut outcomes = Vec::new();
|
||||||
for (label, desc, unit, qty) in [
|
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 a Nos item, whole qty",
|
||||||
("Hour row on an Hour item, fractional qty", "Hourly preset", "hour", 1.5),
|
"Nos preset",
|
||||||
("Nos row on an Hour item, whole qty", "Hourly preset", "unit", 3.0),
|
"hour",
|
||||||
("code-less hours, fractional qty", "Code-less hours", "hour", 1.5),
|
2.0,
|
||||||
("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),
|
"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 inv = live.issue(true, row(desc, unit, qty), json!({}));
|
||||||
let r = live.push(&inv, None).await;
|
let r = live.push(&inv, None).await;
|
||||||
println!("{label}: ok={} {}", r.ok, r.error.clone().unwrap_or_default());
|
println!(
|
||||||
|
"{label}: ok={} {}",
|
||||||
|
r.ok,
|
||||||
|
r.error.clone().unwrap_or_default()
|
||||||
|
);
|
||||||
outcomes.push((label, r.ok));
|
outcomes.push((label, r.ok));
|
||||||
}
|
}
|
||||||
println!("{outcomes:?}");
|
println!("{outcomes:?}");
|
||||||
@@ -548,7 +784,10 @@ async fn live_uom_and_quantities() {
|
|||||||
assert!(ok("Hour row on an Hour item, fractional qty"));
|
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 hours, fractional qty") && ok("code-less minutes, fractional qty"));
|
||||||
assert!(ok("code-less Nos, whole qty"));
|
assert!(ok("code-less Nos, whole qty"));
|
||||||
assert!(!ok("code-less Nos, fractional qty"), "ERPNext refuses a fraction of a whole-number UOM");
|
assert!(
|
||||||
|
!ok("code-less Nos, fractional qty"),
|
||||||
|
"ERPNext refuses a fraction of a whole-number UOM"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- payments ----
|
// ---- payments ----
|
||||||
@@ -569,8 +808,32 @@ async fn live_payment_entry() {
|
|||||||
assert!(r.ok, "{:?}", r.error);
|
assert!(r.ok, "{:?}", r.error);
|
||||||
let entry = r.entry_name.clone().unwrap();
|
let entry = r.entry_name.clone().unwrap();
|
||||||
let pe = live.doc("Payment Entry", &entry).await;
|
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"];
|
let keys = [
|
||||||
println!("PE {}", json!(keys.iter().map(|k| (k.to_string(), pe[*k].clone())).collect::<serde_json::Map<_, _>>()));
|
"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 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(","));
|
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["paid_amount"]), cash);
|
||||||
@@ -580,14 +843,29 @@ async fn live_payment_entry() {
|
|||||||
let ded = pe["deductions"].as_array().unwrap();
|
let ded = pe["deductions"].as_array().unwrap();
|
||||||
assert_eq!(ded.len(), 1);
|
assert_eq!(ded.len(), 1);
|
||||||
assert_eq!(ded[0]["account"], live.cfg().tds_account.as_str());
|
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!(
|
||||||
|
paise(&ded[0]["amount"]),
|
||||||
|
tds,
|
||||||
|
"a TDS deduction row is a positive amount"
|
||||||
|
);
|
||||||
assert_eq!(pe["reference_no"], "UTR-LIVE-1");
|
assert_eq!(pe["reference_no"], "UTR-LIVE-1");
|
||||||
assert_eq!(pe["posting_date"], "2026-06-20");
|
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");
|
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;
|
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
|
||||||
println!("outstanding after payment: {}", inv_doc["outstanding_amount"]);
|
println!(
|
||||||
|
"outstanding after payment: {}",
|
||||||
|
inv_doc["outstanding_amount"]
|
||||||
|
);
|
||||||
assert_eq!(pe["docstatus"], 1);
|
assert_eq!(pe["docstatus"], 1);
|
||||||
assert_eq!(paise(&inv_doc["outstanding_amount"]), 0, "cash plus TDS settles the invoice");
|
assert_eq!(
|
||||||
|
paise(&inv_doc["outstanding_amount"]),
|
||||||
|
0,
|
||||||
|
"cash plus TDS settles the invoice"
|
||||||
|
);
|
||||||
// Idempotent locally.
|
// Idempotent locally.
|
||||||
let again = push_payment(&live.db, &live.http, pid).await;
|
let again = push_payment(&live.db, &live.http, pid).await;
|
||||||
assert!(again.ok && again.already_synced);
|
assert!(again.ok && again.already_synced);
|
||||||
@@ -597,14 +875,22 @@ async fn live_payment_entry() {
|
|||||||
#[ignore = "needs the live ERPNext stack"]
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
async fn live_payment_without_tds() {
|
async fn live_payment_without_tds() {
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let inv = live.issue(true, json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
let inv = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
assert_pushed(&live.push(&inv, Some(true)).await);
|
assert_pushed(&live.push(&inv, Some(true)).await);
|
||||||
let total = crate::gst::rupees_to_paise(inv.total);
|
let total = crate::gst::rupees_to_paise(inv.total);
|
||||||
let pid = live.payment(&inv, total / 2, 0);
|
let pid = live.payment(&inv, total / 2, 0);
|
||||||
let r = push_payment(&live.db, &live.http, pid).await;
|
let r = push_payment(&live.db, &live.http, pid).await;
|
||||||
assert!(r.ok, "{:?}", r.error);
|
assert!(r.ok, "{:?}", r.error);
|
||||||
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
|
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");
|
assert_eq!(
|
||||||
|
paise(&inv_doc["outstanding_amount"]),
|
||||||
|
total - total / 2,
|
||||||
|
"partial payment leaves the rest outstanding"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- India Compliance only (ERPNEXT_IC=1) ----
|
// ---- India Compliance only (ERPNEXT_IC=1) ----
|
||||||
@@ -625,10 +911,18 @@ async fn live_ic_hsn_enforcement() {
|
|||||||
}
|
}
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let mut outcomes = Vec::new();
|
let mut outcomes = Vec::new();
|
||||||
for (label, hsn) in [("no HSN", ""), ("4-digit HSN", "9983"), ("6-digit HSN", "998314"), ("8-digit HSN", "99831400")] {
|
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 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;
|
let r = live.push(&inv, Some(true)).await;
|
||||||
println!("{label}: ok={} docstatus={} err={:?}", r.ok, r.remote_docstatus, r.error);
|
println!(
|
||||||
|
"{label}: ok={} docstatus={} err={:?}",
|
||||||
|
r.ok, r.remote_docstatus, r.error
|
||||||
|
);
|
||||||
outcomes.push((label, r));
|
outcomes.push((label, r));
|
||||||
}
|
}
|
||||||
let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
|
let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
|
||||||
@@ -653,7 +947,14 @@ async fn live_ic_number_longer_than_16_characters() {
|
|||||||
// Voiced never issues such a number; this is a number imported or migrated from elsewhere.
|
// Voiced never issues such a number; this is a number imported or migrated from elsewhere.
|
||||||
let long = format!("{}-EXTRA-LONG-PART", inv.number);
|
let long = format!("{}-EXTRA-LONG-PART", inv.number);
|
||||||
assert!(long.len() > 16);
|
assert!(long.len() > 16);
|
||||||
live.db.lock().unwrap().execute("UPDATE invoices SET number = ?1 WHERE id = ?2", params![long, inv.id]).unwrap();
|
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;
|
let r = live.push(&inv, None).await;
|
||||||
println!("long number: ok={} err={:?}", r.ok, r.error);
|
println!("long number: ok={} err={:?}", r.ok, r.error);
|
||||||
assert!(!r.ok);
|
assert!(!r.ok);
|
||||||
@@ -668,26 +969,55 @@ async fn live_ic_gst_account_warnings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let local = LocalFacts { vendor_registered: true, ..Default::default() };
|
let local = LocalFacts {
|
||||||
let codes = |ct: &ConnectionTest| ct.warnings.iter().map(|w| w.code.clone()).collect::<Vec<_>>();
|
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();
|
let clean = test_connection(&live.http, &live.cfg(), &local)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(clean.warnings.is_empty(), "{:?}", clean.warnings);
|
assert!(clean.warnings.is_empty(), "{:?}", clean.warnings);
|
||||||
|
|
||||||
let mut cfg = live.cfg();
|
let mut cfg = live.cfg();
|
||||||
cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr);
|
cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr);
|
||||||
let wrong = test_connection(&live.http, &cfg, &local).await.unwrap();
|
let wrong = test_connection(&live.http, &cfg, &local).await.unwrap();
|
||||||
assert_eq!(codes(&wrong), ["gst_account_mismatch"], "{:?}", wrong.warnings);
|
assert_eq!(
|
||||||
|
codes(&wrong),
|
||||||
|
["gst_account_mismatch"],
|
||||||
|
"{:?}",
|
||||||
|
wrong.warnings
|
||||||
|
);
|
||||||
|
|
||||||
let mut cfg = live.cfg();
|
let mut cfg = live.cfg();
|
||||||
cfg.company = "No Such Co".into();
|
cfg.company = "No Such Co".into();
|
||||||
let other = test_connection(&live.http, &cfg, &local).await.unwrap();
|
let other = test_connection(&live.http, &cfg, &local).await.unwrap();
|
||||||
assert!(codes(&other).contains(&"gst_accounts_not_configured".to_string()), "{:?}", other.warnings);
|
assert!(
|
||||||
|
codes(&other).contains(&"gst_accounts_not_configured".to_string()),
|
||||||
|
"{:?}",
|
||||||
|
other.warnings
|
||||||
|
);
|
||||||
|
|
||||||
// Mirror mode with a number the site would refuse.
|
// 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 local = LocalFacts {
|
||||||
let ct = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
|
vendor_registered: true,
|
||||||
assert!(codes(&ct).contains(&"number_invalid".to_string()), "{:?}", ct.warnings);
|
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]
|
#[tokio::test]
|
||||||
@@ -702,7 +1032,11 @@ async fn live_ic_reverse_charge_and_gst_fields() {
|
|||||||
// India Compliance refuses is_reverse_charge=1 without negative RCM tax rows ("Booked reverse charge is not
|
// 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.
|
// equal to applied tax amount"), whatever GST Settings say, so it is sent as 0 with a warning.
|
||||||
assert_pushed(&r);
|
assert_pushed(&r);
|
||||||
assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings);
|
assert!(
|
||||||
|
r.warnings.iter().any(|w| w.contains("reverse charge")),
|
||||||
|
"{:?}",
|
||||||
|
r.warnings
|
||||||
|
);
|
||||||
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
let doc = live.doc("Sales Invoice", &r.remote_name).await;
|
||||||
assert_eq!(doc["is_reverse_charge"], 0);
|
assert_eq!(doc["is_reverse_charge"], 0);
|
||||||
assert_matches(&live, &inv, &doc).await;
|
assert_matches(&live, &inv, &doc).await;
|
||||||
@@ -718,13 +1052,34 @@ async fn live_ic_state_names_match_voiced() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let v = live.http.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Address".to_string())]).await.unwrap();
|
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 docs = v["docs"].as_array().unwrap();
|
||||||
let address = docs.iter().find(|d| d["name"] == "Address").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 options = address["fields"]
|
||||||
let ic: Vec<&str> = options["options"].as_str().unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).collect();
|
.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 {
|
for (code, name) in crate::gst::STATES {
|
||||||
assert!(ic.contains(name), "India Compliance has no state named {name:?} ({code}); it has {ic:?}");
|
assert!(
|
||||||
|
ic.contains(name),
|
||||||
|
"India Compliance has no state named {name:?} ({code}); it has {ic:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}");
|
assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}");
|
||||||
}
|
}
|
||||||
@@ -733,23 +1088,40 @@ async fn live_ic_state_names_match_voiced() {
|
|||||||
#[ignore = "needs the live ERPNext stack"]
|
#[ignore = "needs the live ERPNext stack"]
|
||||||
async fn live_payment_edge_cases() {
|
async fn live_payment_edge_cases() {
|
||||||
let live = Live::new(|_| {}).await;
|
let live = Live::new(|_| {}).await;
|
||||||
let inv = live.issue(true, json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
|
let inv = live.issue(
|
||||||
|
true,
|
||||||
|
json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
// Not submitted yet: refused locally, nothing sent.
|
// Not submitted yet: refused locally, nothing sent.
|
||||||
assert_pushed(&live.push(&inv, Some(false)).await);
|
assert_pushed(&live.push(&inv, Some(false)).await);
|
||||||
let early = live.payment(&inv, 100_00, 0);
|
let early = live.payment(&inv, 100_00, 0);
|
||||||
let r = push_payment(&live.db, &live.http, early).await;
|
let r = push_payment(&live.db, &live.http, early).await;
|
||||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("not submitted"), "{r:?}");
|
assert!(
|
||||||
|
!r.ok && r.error.as_deref().unwrap().contains("not submitted"),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
assert_pushed(&live.push(&inv, Some(true)).await);
|
assert_pushed(&live.push(&inv, Some(true)).await);
|
||||||
let total = crate::gst::rupees_to_paise(inv.total);
|
let total = crate::gst::rupees_to_paise(inv.total);
|
||||||
// TDS only (no cash): settled by deduction alone.
|
// TDS only (no cash): settled by deduction alone.
|
||||||
let tds_only = live.payment(&inv, 0, total - 100_00);
|
let tds_only = live.payment(&inv, 0, total - 100_00);
|
||||||
let r = push_payment(&live.db, &live.http, tds_only).await;
|
let r = push_payment(&live.db, &live.http, tds_only).await;
|
||||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS only"), "{r:?}");
|
assert!(
|
||||||
|
!r.ok && r.error.as_deref().unwrap().contains("TDS only"),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
// Without a configured TDS account a TDS payment is refused locally.
|
// Without a configured TDS account a TDS payment is refused locally.
|
||||||
live.save_cfg(|c| c.tds_account = String::new());
|
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!({}));
|
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);
|
assert_pushed(&live.push(&inv2, Some(true)).await);
|
||||||
let t = live.payment(&inv2, 100_00, 50_00);
|
let t = live.payment(&inv2, 100_00, 50_00);
|
||||||
let r = push_payment(&live.db, &live.http, t).await;
|
let r = push_payment(&live.db, &live.http, t).await;
|
||||||
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS account"), "{r:?}");
|
assert!(
|
||||||
|
!r.ok && r.error.as_deref().unwrap().contains("TDS account"),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,12 @@ pub fn remarks_marker(number: &str) -> String {
|
|||||||
fn verified_totals(inv: &Invoice) -> Result<(TaxType, i64, Totals), String> {
|
fn verified_totals(inv: &Invoice) -> Result<(TaxType, i64, Totals), String> {
|
||||||
let tax_type = parse_tax_type(&inv.tax_type)?;
|
let tax_type = parse_tax_type(&inv.tax_type)?;
|
||||||
let rate_bp = (inv.tax_rate * 100.0).round() as i64;
|
let rate_bp = (inv.tax_rate * 100.0).round() as i64;
|
||||||
let totals = gst::compute_totals(&inv.items, gst::rupees_to_paise(inv.discount), tax_type, rate_bp);
|
let totals = gst::compute_totals(
|
||||||
|
&inv.items,
|
||||||
|
gst::rupees_to_paise(inv.discount),
|
||||||
|
tax_type,
|
||||||
|
rate_bp,
|
||||||
|
);
|
||||||
let stored = [
|
let stored = [
|
||||||
(totals.cgst, inv.cgst_amount, "CGST"),
|
(totals.cgst, inv.cgst_amount, "CGST"),
|
||||||
(totals.sgst, inv.sgst_amount, "SGST"),
|
(totals.sgst, inv.sgst_amount, "SGST"),
|
||||||
@@ -199,13 +204,21 @@ fn distinct_descriptions(items: &[InvoiceItem]) -> Vec<String> {
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, item)| {
|
.map(|(i, item)| {
|
||||||
let trimmed = item.description.trim();
|
let trimmed = item.description.trim();
|
||||||
let full = if trimmed.is_empty() { format!("Item {}", i + 1) } else { trimmed.to_string() };
|
let full = if trimmed.is_empty() {
|
||||||
|
format!("Item {}", i + 1)
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
};
|
||||||
let (head, tail) = first_line_and_rest(&full);
|
let (head, tail) = first_line_and_rest(&full);
|
||||||
let mut candidate = full.clone();
|
let mut candidate = full.clone();
|
||||||
let mut n = 2;
|
let mut n = 2;
|
||||||
while used.contains(&candidate.to_lowercase()) {
|
while used.contains(&candidate.to_lowercase()) {
|
||||||
let head = format!("{head} ({n})");
|
let head = format!("{head} ({n})");
|
||||||
candidate = if tail.is_empty() { head } else { format!("{head}\n{tail}") };
|
candidate = if tail.is_empty() {
|
||||||
|
head
|
||||||
|
} else {
|
||||||
|
format!("{head}\n{tail}")
|
||||||
|
};
|
||||||
n += 1;
|
n += 1;
|
||||||
}
|
}
|
||||||
used.insert(candidate.to_lowercase());
|
used.insert(candidate.to_lowercase());
|
||||||
@@ -271,7 +284,10 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let (head, _) = first_line_and_rest(&descriptions[i]);
|
let (head, _) = first_line_and_rest(&descriptions[i]);
|
||||||
row.insert("item_name".into(), json!(truncate_chars(head, ITEM_NAME_MAX)));
|
row.insert(
|
||||||
|
"item_name".into(),
|
||||||
|
json!(truncate_chars(head, ITEM_NAME_MAX)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row.insert("description".into(), json!(descriptions[i]));
|
row.insert("description".into(), json!(descriptions[i]));
|
||||||
@@ -296,7 +312,9 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
|
|||||||
|
|
||||||
fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
|
fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
|
||||||
if account.trim().is_empty() {
|
if account.trim().is_empty() {
|
||||||
return Err(format!("The {head} account is not set in the ERPNext settings."));
|
return Err(format!(
|
||||||
|
"The {head} account is not set in the ERPNext settings."
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"charge_type": "On Net Total",
|
"charge_type": "On Net Total",
|
||||||
@@ -306,10 +324,19 @@ fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state: &str) -> Result<Vec<Value>, String> {
|
fn tax_rows(
|
||||||
|
cfg: &ErpnextConfig,
|
||||||
|
tax_type: TaxType,
|
||||||
|
rate_bp: i64,
|
||||||
|
supplier_state: &str,
|
||||||
|
) -> Result<Vec<Value>, String> {
|
||||||
match tax_type {
|
match tax_type {
|
||||||
TaxType::None => Ok(Vec::new()),
|
TaxType::None => Ok(Vec::new()),
|
||||||
TaxType::Igst => Ok(vec![tax_row(&cfg.igst_account, "IGST", &scaled_decimal(rate_bp, 2))?]),
|
TaxType::Igst => Ok(vec![tax_row(
|
||||||
|
&cfg.igst_account,
|
||||||
|
"IGST",
|
||||||
|
&scaled_decimal(rate_bp, 2),
|
||||||
|
)?]),
|
||||||
TaxType::CgstSgst => {
|
TaxType::CgstSgst => {
|
||||||
// rate_bp is in hundredths of a percent; half of it in thousandths of a percent is rate_bp * 5.
|
// rate_bp is in hundredths of a percent; half of it in thousandths of a percent is rate_bp * 5.
|
||||||
let half = scaled_decimal(rate_bp * 5, 3);
|
let half = scaled_decimal(rate_bp * 5, 3);
|
||||||
@@ -319,7 +346,10 @@ fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state
|
|||||||
} else {
|
} else {
|
||||||
&cfg.sgst_account
|
&cfg.sgst_account
|
||||||
};
|
};
|
||||||
Ok(vec![tax_row(&cfg.cgst_account, "CGST", &half)?, tax_row(second_account, second, &half)?])
|
Ok(vec![
|
||||||
|
tax_row(&cfg.cgst_account, "CGST", &half)?,
|
||||||
|
tax_row(second_account, second, &half)?,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +358,10 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
|
|||||||
let inv = ctx.invoice;
|
let inv = ctx.invoice;
|
||||||
let cfg = ctx.config;
|
let cfg = ctx.config;
|
||||||
if inv.status != "issued" {
|
if inv.status != "issued" {
|
||||||
return Err(format!("Invoice {} is {}; only issued invoices are sent to ERPNext.", inv.number, inv.status));
|
return Err(format!(
|
||||||
|
"Invoice {} is {}; only issued invoices are sent to ERPNext.",
|
||||||
|
inv.number, inv.status
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if ctx.customer.trim().is_empty() {
|
if ctx.customer.trim().is_empty() {
|
||||||
return Err("The ERPNext customer is not resolved.".into());
|
return Err("The ERPNext customer is not resolved.".into());
|
||||||
@@ -346,7 +379,9 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
|
|||||||
}
|
}
|
||||||
NamingMode::Series => {
|
NamingMode::Series => {
|
||||||
if cfg.naming_series.trim().is_empty() {
|
if cfg.naming_series.trim().is_empty() {
|
||||||
return Err("ERPNext series naming is selected but no naming series is set.".into());
|
return Err(
|
||||||
|
"ERPNext series naming is selected but no naming series is set.".into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
body.insert("naming_series".into(), json!(cfg.naming_series.trim()));
|
body.insert("naming_series".into(), json!(cfg.naming_series.trim()));
|
||||||
}
|
}
|
||||||
@@ -396,14 +431,21 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
|
|||||||
body.insert("discount_amount".into(), money(totals.discount));
|
body.insert("discount_amount".into(), money(totals.discount));
|
||||||
}
|
}
|
||||||
body.insert("items".into(), Value::Array(item_rows(ctx, gst_fields)?));
|
body.insert("items".into(), Value::Array(item_rows(ctx, gst_fields)?));
|
||||||
body.insert("taxes".into(), Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?));
|
body.insert(
|
||||||
|
"taxes".into(),
|
||||||
|
Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?),
|
||||||
|
);
|
||||||
if ctx.submit {
|
if ctx.submit {
|
||||||
body.insert("docstatus".into(), json!(1));
|
body.insert("docstatus".into(), json!(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mirror = cfg.naming_mode == NamingMode::Mirror;
|
let mirror = cfg.naming_mode == NamingMode::Mirror;
|
||||||
Ok(BuiltRequest {
|
Ok(BuiltRequest {
|
||||||
path: if mirror { SALES_INVOICE_V2 } else { SALES_INVOICE_V1 },
|
path: if mirror {
|
||||||
|
SALES_INVOICE_V2
|
||||||
|
} else {
|
||||||
|
SALES_INVOICE_V1
|
||||||
|
},
|
||||||
body: Value::Object(body),
|
body: Value::Object(body),
|
||||||
idempotent: mirror,
|
idempotent: mirror,
|
||||||
})
|
})
|
||||||
@@ -416,7 +458,11 @@ fn has_gstin(client: &Client) -> bool {
|
|||||||
!g.is_empty() && !g.eq_ignore_ascii_case("NA")
|
!g.is_empty() && !g.eq_ignore_ascii_case("NA")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bool) -> Result<BuiltRequest, String> {
|
pub fn build_customer(
|
||||||
|
client: &Client,
|
||||||
|
cfg: &ErpnextConfig,
|
||||||
|
india_compliance: bool,
|
||||||
|
) -> Result<BuiltRequest, String> {
|
||||||
let name = client.name.trim();
|
let name = client.name.trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err("The client has no name.".into());
|
return Err("The client has no name.".into());
|
||||||
@@ -427,19 +473,33 @@ pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bo
|
|||||||
set_if(&mut body, "customer_group", &cfg.customer_group);
|
set_if(&mut body, "customer_group", &cfg.customer_group);
|
||||||
set_if(&mut body, "territory", &cfg.territory);
|
set_if(&mut body, "territory", &cfg.territory);
|
||||||
if india_compliance {
|
if india_compliance {
|
||||||
body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?));
|
body.insert(
|
||||||
let taxable_registration = matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez");
|
"gst_category".into(),
|
||||||
|
json!(gst_category_label(&client.gst_category)?),
|
||||||
|
);
|
||||||
|
let taxable_registration = matches!(
|
||||||
|
client.gst_category.as_str(),
|
||||||
|
"registered_regular" | "composition" | "sez"
|
||||||
|
);
|
||||||
if taxable_registration && has_gstin(client) {
|
if taxable_registration && has_gstin(client) {
|
||||||
let gstin = client.gstin.trim().to_ascii_uppercase();
|
let gstin = client.gstin.trim().to_ascii_uppercase();
|
||||||
gst::validate_gstin(&gstin).map_err(|e| format!("Client GSTIN: {e}"))?;
|
gst::validate_gstin(&gstin).map_err(|e| format!("Client GSTIN: {e}"))?;
|
||||||
body.insert("gstin".into(), json!(gstin));
|
body.insert("gstin".into(), json!(gstin));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(BuiltRequest { path: CUSTOMER, body: Value::Object(body), idempotent: false })
|
Ok(BuiltRequest {
|
||||||
|
path: CUSTOMER,
|
||||||
|
body: Value::Object(body),
|
||||||
|
idempotent: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `customer` is the name ERPNext returned when the Customer was created (a duplicate becomes "X - 1").
|
/// `customer` is the name ERPNext returned when the Customer was created (a duplicate becomes "X - 1").
|
||||||
pub fn build_address(client: &Client, customer: &str, india_compliance: bool) -> Result<BuiltRequest, String> {
|
pub fn build_address(
|
||||||
|
client: &Client,
|
||||||
|
customer: &str,
|
||||||
|
india_compliance: bool,
|
||||||
|
) -> Result<BuiltRequest, String> {
|
||||||
if customer.trim().is_empty() {
|
if customer.trim().is_empty() {
|
||||||
return Err("The ERPNext customer is not resolved.".into());
|
return Err("The ERPNext customer is not resolved.".into());
|
||||||
}
|
}
|
||||||
@@ -472,16 +532,31 @@ pub fn build_address(client: &Client, customer: &str, india_compliance: bool) ->
|
|||||||
set_if(&mut body, "pincode", &client.pincode);
|
set_if(&mut body, "pincode", &client.pincode);
|
||||||
body.insert("country".into(), json!("India"));
|
body.insert("country".into(), json!("India"));
|
||||||
if india_compliance {
|
if india_compliance {
|
||||||
body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?));
|
body.insert(
|
||||||
if has_gstin(client) && matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez") {
|
"gst_category".into(),
|
||||||
body.insert("gstin".into(), json!(client.gstin.trim().to_ascii_uppercase()));
|
json!(gst_category_label(&client.gst_category)?),
|
||||||
|
);
|
||||||
|
if has_gstin(client)
|
||||||
|
&& matches!(
|
||||||
|
client.gst_category.as_str(),
|
||||||
|
"registered_regular" | "composition" | "sez"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
body.insert(
|
||||||
|
"gstin".into(),
|
||||||
|
json!(client.gstin.trim().to_ascii_uppercase()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body.insert(
|
body.insert(
|
||||||
"links".into(),
|
"links".into(),
|
||||||
json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]),
|
json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]),
|
||||||
);
|
);
|
||||||
Ok(BuiltRequest { path: ADDRESS, body: Value::Object(body), idempotent: false })
|
Ok(BuiltRequest {
|
||||||
|
path: ADDRESS,
|
||||||
|
body: Value::Object(body),
|
||||||
|
idempotent: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -539,7 +614,13 @@ mod tests {
|
|||||||
Vendor::from_snapshot(&inv.vendor_snapshot).unwrap()
|
Vendor::from_snapshot(&inv.vendor_snapshot).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build(inv: &Invoice, cfg: &ErpnextConfig, ic: bool, codes: &[Option<String>], submit: bool) -> Result<BuiltRequest, String> {
|
fn build(
|
||||||
|
inv: &Invoice,
|
||||||
|
cfg: &ErpnextConfig,
|
||||||
|
ic: bool,
|
||||||
|
codes: &[Option<String>],
|
||||||
|
submit: bool,
|
||||||
|
) -> Result<BuiltRequest, String> {
|
||||||
let v = vendor(inv);
|
let v = vendor(inv);
|
||||||
build_sales_invoice(&InvoiceContext {
|
build_sales_invoice(&InvoiceContext {
|
||||||
invoice: inv,
|
invoice: inv,
|
||||||
@@ -597,7 +678,10 @@ mod tests {
|
|||||||
assert_eq!(scaled_decimal(1500, 3), "1.5");
|
assert_eq!(scaled_decimal(1500, 3), "1.5");
|
||||||
assert_eq!(scaled_decimal(2625, 3), "2.625");
|
assert_eq!(scaled_decimal(2625, 3), "2.625");
|
||||||
assert_eq!(money(731_005).to_string(), "7310.05");
|
assert_eq!(money(731_005).to_string(), "7310.05");
|
||||||
assert_eq!(money(i64::from(u32::MAX) * 100 + 7).to_string(), "4294967295.07");
|
assert_eq!(
|
||||||
|
money(i64::from(u32::MAX) * 100 + 7).to_string(),
|
||||||
|
"4294967295.07"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -607,7 +691,10 @@ mod tests {
|
|||||||
assert_eq!(place_of_supply_label("97").unwrap(), "97-Other Territory");
|
assert_eq!(place_of_supply_label("97").unwrap(), "97-Other Territory");
|
||||||
assert_eq!(place_of_supply_label("96").unwrap(), "96-Other Countries");
|
assert_eq!(place_of_supply_label("96").unwrap(), "96-Other Countries");
|
||||||
for (code, name) in gst::STATES {
|
for (code, name) in gst::STATES {
|
||||||
assert_eq!(place_of_supply_label(code).unwrap(), format!("{code}-{name}"));
|
assert_eq!(
|
||||||
|
place_of_supply_label(code).unwrap(),
|
||||||
|
format!("{code}-{name}")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert!(place_of_supply_label("28").is_err());
|
assert!(place_of_supply_label("28").is_err());
|
||||||
}
|
}
|
||||||
@@ -750,10 +837,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn plain_erpnext_registered_vendor_gets_tax_rows_but_no_india_compliance_fields() {
|
fn plain_erpnext_registered_vendor_gets_tax_rows_but_no_india_compliance_fields() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
let inv = issue(&mut conn, json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }));
|
let inv = issue(
|
||||||
|
&mut conn,
|
||||||
|
json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }),
|
||||||
|
);
|
||||||
let req = build(&inv, &cfg(), false, &[None], false).unwrap();
|
let req = build(&inv, &cfg(), false, &[None], false).unwrap();
|
||||||
assert_eq!(req.body["taxes"].as_array().unwrap().len(), 2);
|
assert_eq!(req.body["taxes"].as_array().unwrap().len(), 2);
|
||||||
assert!(req.body.get("place_of_supply").is_none() && req.body.get("is_reverse_charge").is_none());
|
assert!(
|
||||||
|
req.body.get("place_of_supply").is_none()
|
||||||
|
&& req.body.get("is_reverse_charge").is_none()
|
||||||
|
);
|
||||||
assert!(req.body["items"][0].get("gst_hsn_code").is_none());
|
assert!(req.body["items"][0].get("gst_hsn_code").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,8 +888,17 @@ mod tests {
|
|||||||
let req = build(&inv, &cfg(), false, &codes, false).unwrap();
|
let req = build(&inv, &cfg(), false, &codes, false).unwrap();
|
||||||
let rows = req.body["items"].as_array().unwrap();
|
let rows = req.body["items"].as_array().unwrap();
|
||||||
assert!(rows[0].get("stock_uom").is_none());
|
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!(
|
||||||
assert_eq!((rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), (Some("Minute"), Some("Minute")));
|
(
|
||||||
|
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]
|
#[test]
|
||||||
@@ -849,7 +951,10 @@ mod tests {
|
|||||||
assert!(!req.idempotent);
|
assert!(!req.idempotent);
|
||||||
assert!(req.body.get("name").is_none());
|
assert!(req.body.get("name").is_none());
|
||||||
assert_eq!(req.body["naming_series"], "SINV-.YY.-");
|
assert_eq!(req.body["naming_series"], "SINV-.YY.-");
|
||||||
assert_eq!(req.body["remarks"], format!("Voiced invoice {}\nNet 30", inv.number));
|
assert_eq!(
|
||||||
|
req.body["remarks"],
|
||||||
|
format!("Voiced invoice {}\nNet 30", inv.number)
|
||||||
|
);
|
||||||
|
|
||||||
c.naming_series.clear();
|
c.naming_series.clear();
|
||||||
assert!(build(&inv, &c, true, &[None], false).is_err());
|
assert!(build(&inv, &c, true, &[None], false).is_err());
|
||||||
@@ -859,8 +964,15 @@ mod tests {
|
|||||||
fn submit_adds_docstatus_and_drafts_do_not() {
|
fn submit_adds_docstatus_and_drafts_do_not() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
let inv = issue(&mut conn, json!({}));
|
let inv = issue(&mut conn, json!({}));
|
||||||
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("docstatus").is_none());
|
assert!(build(&inv, &cfg(), true, &[None], false)
|
||||||
assert_eq!(build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"], 1);
|
.unwrap()
|
||||||
|
.body
|
||||||
|
.get("docstatus")
|
||||||
|
.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"],
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -874,7 +986,9 @@ mod tests {
|
|||||||
|
|
||||||
// A supplier in a union territory without a legislature charges UTGST, not SGST.
|
// A supplier in a union territory without a legislature charges UTGST, not SGST.
|
||||||
let mut ut = inv.clone();
|
let mut ut = inv.clone();
|
||||||
ut.vendor_snapshot = ut.vendor_snapshot.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\"");
|
ut.vendor_snapshot = ut
|
||||||
|
.vendor_snapshot
|
||||||
|
.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\"");
|
||||||
let req = build(&ut, &cfg(), true, &[None], false).unwrap();
|
let req = build(&ut, &cfg(), true, &[None], false).unwrap();
|
||||||
assert_eq!(req.body["taxes"][1]["account_head"], "Output UTGST - AC");
|
assert_eq!(req.body["taxes"][1]["account_head"], "Output UTGST - AC");
|
||||||
assert_eq!(req.body["taxes"][1]["description"], "UTGST @ 2.625%");
|
assert_eq!(req.body["taxes"][1]["description"], "UTGST @ 2.625%");
|
||||||
@@ -901,11 +1015,17 @@ mod tests {
|
|||||||
let mut inv = issue(&mut conn, json!({}));
|
let mut inv = issue(&mut conn, json!({}));
|
||||||
let mut c = cfg();
|
let mut c = cfg();
|
||||||
c.cgst_account.clear();
|
c.cgst_account.clear();
|
||||||
assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("CGST account"));
|
assert!(build(&inv, &c, true, &[None], false)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("CGST account"));
|
||||||
c.company.clear();
|
c.company.clear();
|
||||||
assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("company"));
|
assert!(build(&inv, &c, true, &[None], false)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("company"));
|
||||||
inv.status = "cancelled".into();
|
inv.status = "cancelled".into();
|
||||||
assert!(build(&inv, &cfg(), true, &[None], false).unwrap_err().contains("only issued"));
|
assert!(build(&inv, &cfg(), true, &[None], false)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("only issued"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -913,9 +1033,17 @@ mod tests {
|
|||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
let mut inv = issue(&mut conn, json!({}));
|
let mut inv = issue(&mut conn, json!({}));
|
||||||
inv.due_date = "2026-03-01".into();
|
inv.due_date = "2026-03-01".into();
|
||||||
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none());
|
assert!(build(&inv, &cfg(), true, &[None], false)
|
||||||
|
.unwrap()
|
||||||
|
.body
|
||||||
|
.get("due_date")
|
||||||
|
.is_none());
|
||||||
inv.due_date.clear();
|
inv.due_date.clear();
|
||||||
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none());
|
assert!(build(&inv, &cfg(), true, &[None], false)
|
||||||
|
.unwrap()
|
||||||
|
.body
|
||||||
|
.get("due_date")
|
||||||
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -960,11 +1088,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let mut unreg = client();
|
let mut unreg = client();
|
||||||
unreg.gst_category = "unregistered".into();
|
unreg.gst_category = "unregistered".into();
|
||||||
assert!(build_customer(&unreg, &cfg(), true).unwrap().body.get("gstin").is_none());
|
assert!(build_customer(&unreg, &cfg(), true)
|
||||||
|
.unwrap()
|
||||||
|
.body
|
||||||
|
.get("gstin")
|
||||||
|
.is_none());
|
||||||
|
|
||||||
let mut bad = client();
|
let mut bad = client();
|
||||||
bad.gstin = "29AABCU9603R1Z0".into();
|
bad.gstin = "29AABCU9603R1Z0".into();
|
||||||
assert!(build_customer(&bad, &cfg(), true).unwrap_err().contains("GSTIN"));
|
assert!(build_customer(&bad, &cfg(), true)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("GSTIN"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -989,7 +1123,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let mut jk = client();
|
let mut jk = client();
|
||||||
jk.state_code = "01".into();
|
jk.state_code = "01".into();
|
||||||
assert_eq!(build_address(&jk, "X", true).unwrap().body["state"], "Jammu and Kashmir");
|
assert_eq!(
|
||||||
|
build_address(&jk, "X", true).unwrap().body["state"],
|
||||||
|
"Jammu and Kashmir"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -997,7 +1134,10 @@ mod tests {
|
|||||||
let mut c = client();
|
let mut c = client();
|
||||||
c.address_line1.clear();
|
c.address_line1.clear();
|
||||||
c.address = "5 Park Street\nKolkata, West Bengal - 700016".into();
|
c.address = "5 Park Street\nKolkata, West Bengal - 700016".into();
|
||||||
assert_eq!(build_address(&c, "X", false).unwrap().body["address_line1"], "5 Park Street");
|
assert_eq!(
|
||||||
|
build_address(&c, "X", false).unwrap().body["address_line1"],
|
||||||
|
"5 Park Street"
|
||||||
|
);
|
||||||
|
|
||||||
let mut no_city = client();
|
let mut no_city = client();
|
||||||
no_city.city.clear();
|
no_city.city.clear();
|
||||||
@@ -1005,7 +1145,11 @@ mod tests {
|
|||||||
let mut no_state = client();
|
let mut no_state = client();
|
||||||
no_state.state_code.clear();
|
no_state.state_code.clear();
|
||||||
assert!(build_address(&no_state, "X", true).is_err());
|
assert!(build_address(&no_state, "X", true).is_err());
|
||||||
assert!(build_address(&no_state, "X", false).unwrap().body.get("state").is_none());
|
assert!(build_address(&no_state, "X", false)
|
||||||
|
.unwrap()
|
||||||
|
.body
|
||||||
|
.get("state")
|
||||||
|
.is_none());
|
||||||
assert!(build_address(&client(), " ", true).is_err());
|
assert!(build_address(&client(), " ", true).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use super::config::{self, ErpnextConfig, NamingMode};
|
|||||||
use super::discovery::ic_number_ok;
|
use super::discovery::ic_number_ok;
|
||||||
use super::errors::{ErpError, ErrorKind};
|
use super::errors::{ErpError, ErrorKind};
|
||||||
use super::mapping::{
|
use super::mapping::{
|
||||||
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, InvoiceContext, Vendor,
|
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker,
|
||||||
SALES_INVOICE_V2,
|
InvoiceContext, Vendor, SALES_INVOICE_V2,
|
||||||
};
|
};
|
||||||
use crate::commands::archive::read_archive_impl;
|
use crate::commands::archive::read_archive_impl;
|
||||||
use crate::commands::invoice::get_invoice_impl;
|
use crate::commands::invoice::get_invoice_impl;
|
||||||
@@ -31,14 +31,20 @@ use std::sync::Mutex;
|
|||||||
type Db = Mutex<Connection>;
|
type Db = Mutex<Connection>;
|
||||||
|
|
||||||
const DOCTYPE_INVOICE: &str = "Sales Invoice";
|
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 {
|
fn pre(message: impl Into<String>) -> ErpError {
|
||||||
ErpError::new(ErrorKind::Precondition, message)
|
ErpError::new(ErrorKind::Precondition, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_db<T>(db: &Db, f: impl FnOnce(&mut Connection) -> Result<T, String>) -> Result<T, ErpError> {
|
fn with_db<T>(
|
||||||
let mut conn = db.lock().map_err(|e| pre(format!("The database is busy: {e}")))?;
|
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}")))
|
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).
|
/// `<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> {
|
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 row = load_sync(conn, invoice_id)?;
|
||||||
let name = row.map(|r| r.remote_name).unwrap_or_default();
|
let name = row.map(|r| r.remote_name).unwrap_or_default();
|
||||||
if name.is_empty() {
|
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 cfg = config::load(conn)?;
|
||||||
let base = super::client::normalize_base_url(&cfg.base_url).map_err(|e| e.to_string())?;
|
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 ----
|
// ---- loading ----
|
||||||
@@ -294,7 +307,12 @@ fn load_client(conn: &Connection, inv: &Invoice) -> Result<ClientRow, String> {
|
|||||||
address_line2: String::new(),
|
address_line2: String::new(),
|
||||||
city: String::new(),
|
city: String::new(),
|
||||||
pincode: 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(),
|
default_notes: String::new(),
|
||||||
payment_terms_days: None,
|
payment_terms_days: None,
|
||||||
invoice_count: 0,
|
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.
|
// ORDER BY id DESC: the oldest preset wins on a repeated description.
|
||||||
by_desc.insert(desc.trim().to_lowercase(), code.trim().to_string());
|
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).
|
/// 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 cfg = config::load(&conn).map_err(|e| fail(pre(e)))?;
|
||||||
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot)
|
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot).ok_or_else(|| {
|
||||||
.ok_or_else(|| fail(pre(format!("Invoice {number} has no supplier details recorded, so it cannot be sent."))))?;
|
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
|
// 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.
|
// connection test detected, so refuse until the test has run once.
|
||||||
if vendor.registered && !has_detect_result(&cfg.last_detect_result) {
|
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()
|
.ok()
|
||||||
.and_then(|v| v.get("indiaCompliance").and_then(Value::as_bool))
|
.and_then(|v| v.get("indiaCompliance").and_then(Value::as_bool))
|
||||||
.unwrap_or(false);
|
.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) ----
|
// ---- address checks (local, before anything is sent) ----
|
||||||
@@ -430,7 +465,10 @@ fn gstin_applies(client: &Client) -> bool {
|
|||||||
let g = client.gstin.trim();
|
let g = client.gstin.trim();
|
||||||
!g.is_empty()
|
!g.is_empty()
|
||||||
&& !g.eq_ignore_ascii_case("NA")
|
&& !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.
|
/// 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();
|
let pin = client.pincode.trim();
|
||||||
if !pin.is_empty() {
|
if !pin.is_empty() {
|
||||||
if pin.len() != 6 || !pin.bytes().all(|b| b.is_ascii_digit()) || pin.starts_with('0') {
|
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 let Some(allowed) = pin_prefixes(state_code) {
|
||||||
if !allowed.iter().any(|p| pin.starts_with(p)) {
|
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> {
|
fn doc_total_paise(doc: &Value) -> Option<i64> {
|
||||||
let v = doc.get("grand_total")?;
|
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))
|
Some(gst::rupees_to_paise(n))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,13 +545,26 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
|
|||||||
"creation asc",
|
"creation asc",
|
||||||
)
|
)
|
||||||
.await?;
|
.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() {
|
if found.is_none() {
|
||||||
let rows = http
|
let rows = http
|
||||||
.list_resource("Customer", &["name"], json!([["customer_name", "=", name]]), "creation asc")
|
.list_resource(
|
||||||
|
"Customer",
|
||||||
|
&["name"],
|
||||||
|
json!([["customer_name", "=", name]]),
|
||||||
|
"creation asc",
|
||||||
|
)
|
||||||
.await?;
|
.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 {
|
let customer = match found {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
@@ -521,12 +577,17 @@ 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 req = build_customer(client, &l.cfg, l.india_compliance).map_err(pre)?;
|
||||||
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
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.
|
// 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 {
|
if let Some(id) = l.client.id {
|
||||||
with_db(db, |c| {
|
with_db(db, |c| {
|
||||||
c.execute("UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2", params![customer, id])
|
c.execute(
|
||||||
|
"UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2",
|
||||||
|
params![customer, id],
|
||||||
|
)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
})?;
|
})?;
|
||||||
@@ -548,16 +609,27 @@ async fn ensure_address(
|
|||||||
let Some(client_id) = l.client.id else {
|
let Some(client_id) = l.client.id else {
|
||||||
return Ok((
|
return Ok((
|
||||||
None,
|
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]
|
let has_address = [
|
||||||
|
&client.address_line1,
|
||||||
|
&client.address,
|
||||||
|
&client.city,
|
||||||
|
&client.pincode,
|
||||||
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.any(|s| !s.trim().is_empty());
|
.any(|s| !s.trim().is_empty());
|
||||||
if !has_address {
|
if !has_address {
|
||||||
return Ok((
|
return Ok((
|
||||||
None,
|
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)?;
|
validate_address(client).map_err(pre)?;
|
||||||
@@ -568,11 +640,16 @@ async fn ensure_address(
|
|||||||
Some(existing) => existing,
|
Some(existing) => existing,
|
||||||
None => {
|
None => {
|
||||||
let resp = http.post(req.path, &req.body, req.idempotent).await?;
|
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| {
|
with_db(db, |c| {
|
||||||
c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id])
|
c.execute(
|
||||||
|
"UPDATE clients SET erpnext_address = ?1 WHERE id = ?2",
|
||||||
|
params![name, client_id],
|
||||||
|
)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
})?;
|
})?;
|
||||||
@@ -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) {
|
if let Some(pin) = body.get("pincode").and_then(Value::as_str) {
|
||||||
filters.push(json!(["pincode", "=", pin]));
|
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)
|
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(
|
.list_resource(
|
||||||
DOCTYPE_INVOICE,
|
DOCTYPE_INVOICE,
|
||||||
&["name", "docstatus", "grand_total", "remarks"],
|
&["name", "docstatus", "grand_total", "remarks"],
|
||||||
json!([["remarks", "like", format!("{marker}%")], ["docstatus", "!=", 2]]),
|
json!([
|
||||||
|
["remarks", "like", format!("{marker}%")],
|
||||||
|
["docstatus", "!=", 2]
|
||||||
|
]),
|
||||||
"creation asc",
|
"creation asc",
|
||||||
)
|
)
|
||||||
.await?;
|
.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;
|
let inv = &l.invoice;
|
||||||
if l.cfg.naming_mode == NamingMode::Series {
|
if l.cfg.naming_mode == NamingMode::Series {
|
||||||
if let Some(found) = find_by_remarks(http, l).await? {
|
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,
|
(Some(n), _) => n,
|
||||||
(None, NamingMode::Mirror) => inv.number.clone(),
|
(None, NamingMode::Mirror) => inv.number.clone(),
|
||||||
(None, NamingMode::Series) => {
|
(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 => {
|
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.
|
// 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?
|
http.post(&path, &json!({}), false).await?
|
||||||
}
|
}
|
||||||
NamingMode::Series => {
|
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.
|
// 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(1) | None => Ok(1),
|
||||||
Some(other) => Err(ErpError::protocol(format!(
|
Some(other) => Err(ErpError::protocol(format!(
|
||||||
"ERPNext accepted the submit request but the document is still at docstatus {other}."
|
"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 {
|
fn attachment_file_name(number: &str) -> String {
|
||||||
let cleaned: String = number
|
let cleaned: String = number
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' })
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let cleaned = cleaned.trim_matches('-');
|
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
|
/// 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.
|
/// 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
|
let rows = http
|
||||||
.list_resource(
|
.list_resource(
|
||||||
"File",
|
"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.
|
/// `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);
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let fields = [
|
let fields = [
|
||||||
@@ -772,14 +907,24 @@ async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf,
|
|||||||
let resp = http
|
let resp = http
|
||||||
.post_file(
|
.post_file(
|
||||||
&["api", "method", "upload_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?;
|
.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 {
|
if ok {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} 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>,
|
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" {
|
let synced_at = if status == "synced" {
|
||||||
Some(chrono::Utc::now().to_rfc3339())
|
Some(chrono::Utc::now().to_rfc3339())
|
||||||
} else {
|
} 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
|
/// 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.
|
/// conflicts already say what is wrong and get no prefix.
|
||||||
fn failure_text(step: &str, e: &ErpError) -> String {
|
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()
|
e.to_string()
|
||||||
} else if e.kind == ErrorKind::Validation && e.message.contains("cannot be a fraction") {
|
} 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.
|
// 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 inv = &l.invoice;
|
||||||
let cfg = &l.cfg;
|
let cfg = &l.cfg;
|
||||||
let prev = l.sync.as_ref();
|
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 => {
|
None => {
|
||||||
st.payload_hash = hash;
|
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!(
|
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.",
|
"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
|
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) {
|
if let (Some(w), None) = (&l.pdf_warning, &l.pdf) {
|
||||||
st.warnings.push(w.clone());
|
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;
|
st.no_op = true;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -933,7 +1103,13 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
|
|||||||
Ok(())
|
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) {
|
let loaded = match load_for_push(db, local_dir, invoice_id) {
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
Err((number, e)) => return PushResult::refused(invoice_id, &number, e),
|
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 {
|
let (error, status): (Option<(ErpError, String)>, &str) = match outcome {
|
||||||
Ok(()) => {
|
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 {
|
match write {
|
||||||
Ok(()) => (None, "synced"),
|
Ok(()) => (None, "synced"),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -957,7 +1137,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let text = failure_text(st.step, &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.
|
// Best effort: the original error is what the caller needs to see.
|
||||||
let _ = persist(db, invoice_id, prev, &st, status, &text);
|
let _ = persist(db, invoice_id, prev, &st, status, &text);
|
||||||
(Some((e, text)), status)
|
(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
|
/// `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.
|
/// cash + TDS. A partial payment without TDS leaves the rest outstanding.
|
||||||
pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result<Value, String> {
|
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.retain(|k, _| !k.starts_with("__"));
|
||||||
doc.insert("doctype".into(), json!("Payment Entry"));
|
doc.insert("doctype".into(), json!("Payment Entry"));
|
||||||
doc.insert("posting_date".into(), json!(p.paid_on));
|
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_no".into(), json!(reference));
|
||||||
doc.insert("reference_date".into(), json!(p.paid_on));
|
doc.insert("reference_date".into(), json!(p.paid_on));
|
||||||
doc.insert("paid_amount".into(), mapping::money(p.cash_paise));
|
doc.insert("paid_amount".into(), mapping::money(p.cash_paise));
|
||||||
doc.insert("received_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 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
|
let target = refs
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|r| r.get("reference_name").and_then(Value::as_str) == Some(p.remote_invoice))
|
.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;
|
target["allocated_amount"] = allocated;
|
||||||
|
|
||||||
let deductions = if p.tds_paise > 0 {
|
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())?
|
.map_err(|e| e.to_string())?
|
||||||
.ok_or_else(|| "Payment not found".to_string())?;
|
.ok_or_else(|| "Payment not found".to_string())?;
|
||||||
let number: String = conn
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let sync = load_sync(conn, row.invoice_id)?;
|
let sync = load_sync(conn, row.invoice_id)?;
|
||||||
let cfg = config::load(conn)?;
|
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() {
|
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() {
|
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 = [
|
let query = [
|
||||||
("dt", DOCTYPE_INVOICE.to_string()),
|
("dt", DOCTYPE_INVOICE.to_string()),
|
||||||
("dn", remote_invoice.clone()),
|
("dn", remote_invoice.clone()),
|
||||||
("bank_account", cfg.payment_bank_account.trim().to_string()),
|
("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),
|
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(
|
let body = match build_payment_entry(
|
||||||
&draft,
|
&draft,
|
||||||
@@ -1178,15 +1399,25 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
|
|||||||
Err(e) => return fail(ErpError::protocol(e)),
|
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.
|
// 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,
|
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 {
|
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| {
|
if let Err(e) = with_db(db, |c| {
|
||||||
c.execute("UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2", params![name, payment_id])
|
c.execute(
|
||||||
|
"UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2",
|
||||||
|
params![name, payment_id],
|
||||||
|
)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}) {
|
}) {
|
||||||
@@ -1223,7 +1454,14 @@ impl InvoiceSink for ErpnextSink<'_> {
|
|||||||
request: PushRequest,
|
request: PushRequest,
|
||||||
) -> impl std::future::Future<Output = Result<PushedInvoice, String>> + Send + 'a {
|
) -> impl std::future::Future<Output = Result<PushedInvoice, String>> + Send + 'a {
|
||||||
async move {
|
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 {
|
if r.ok {
|
||||||
Ok(PushedInvoice {
|
Ok(PushedInvoice {
|
||||||
remote_name: r.remote_name,
|
remote_name: r.remote_name,
|
||||||
@@ -1244,7 +1482,10 @@ impl InvoiceSink for ErpnextSink<'_> {
|
|||||||
async move {
|
async move {
|
||||||
let r = push_payment(self.db, self.http, request.payment_id).await;
|
let r = push_payment(self.db, self.http, request.payment_id).await;
|
||||||
match (r.ok, r.entry_name) {
|
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())),
|
_ => Err(r.error.unwrap_or_else(|| "The payment push failed.".into())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,12 @@ pub struct Reply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn reply(status: u16, body: Value) -> Reply {
|
pub fn reply(status: u16, body: Value) -> Reply {
|
||||||
Reply { status, headers: vec![], body: body.to_string(), delay_ms: 0 }
|
Reply {
|
||||||
|
status,
|
||||||
|
headers: vec![],
|
||||||
|
body: body.to_string(),
|
||||||
|
delay_ms: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Mock {
|
pub struct Mock {
|
||||||
@@ -33,7 +38,12 @@ pub async fn serve(script: Vec<Reply>) -> Mock {
|
|||||||
let script = Arc::new(script);
|
let script = Arc::new(script);
|
||||||
serve_fn(move |i, _| {
|
serve_fn(move |i, _| {
|
||||||
let r = &script[i.min(script.len() - 1)];
|
let r = &script[i.min(script.len() - 1)];
|
||||||
Reply { status: r.status, headers: r.headers.clone(), body: r.body.clone(), delay_ms: r.delay_ms }
|
Reply {
|
||||||
|
status: r.status,
|
||||||
|
headers: r.headers.clone(),
|
||||||
|
body: r.body.clone(),
|
||||||
|
delay_ms: r.delay_ms,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -48,7 +58,9 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st
|
|||||||
let handler = Arc::new(handler);
|
let handler = Arc::new(handler);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
let Ok((mut sock, _)) = listener.accept().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let (h, r, handler) = (h.clone(), r.clone(), handler.clone());
|
let (h, r, handler) = (h.clone(), r.clone(), handler.clone());
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
@@ -97,7 +109,11 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Mock { base: format!("http://127.0.0.1:{port}"), hits, requests }
|
Mock {
|
||||||
|
base: format!("http://127.0.0.1:{port}"),
|
||||||
|
hits,
|
||||||
|
requests,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short timeouts and near-zero backoff so retry tests run in milliseconds.
|
/// Short timeouts and near-zero backoff so retry tests run in milliseconds.
|
||||||
@@ -113,5 +129,12 @@ pub fn fast_opts() -> ClientOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn client(mock: &Mock) -> ErpClient {
|
pub fn client(mock: &Mock) -> ErpClient {
|
||||||
ErpClient::new(&mock.base, "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap()
|
ErpClient::new(
|
||||||
|
&mock.base,
|
||||||
|
"thekey",
|
||||||
|
&Secret::new("thesecret"),
|
||||||
|
"",
|
||||||
|
fast_opts(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-8
@@ -38,23 +38,39 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
let data_dir = match app.path().app_data_dir() {
|
let data_dir = match app.path().app_data_dir() {
|
||||||
Ok(dir) => dir,
|
Ok(dir) => dir,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
show_startup_error(app, &format!("Could not resolve the application data directory: {e}"), None);
|
show_startup_error(
|
||||||
|
app,
|
||||||
|
&format!("Could not resolve the application data directory: {e}"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
||||||
show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir));
|
show_startup_error(
|
||||||
|
app,
|
||||||
|
&format!("Could not create the application data directory: {e}"),
|
||||||
|
Some(&data_dir),
|
||||||
|
);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
let local_data_dir = match app.path().app_local_data_dir() {
|
let local_data_dir = match app.path().app_local_data_dir() {
|
||||||
Ok(dir) => dir,
|
Ok(dir) => dir,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
show_startup_error(app, &format!("Could not resolve the local data directory: {e}"), Some(&data_dir));
|
show_startup_error(
|
||||||
|
app,
|
||||||
|
&format!("Could not resolve the local data directory: {e}"),
|
||||||
|
Some(&data_dir),
|
||||||
|
);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = std::fs::create_dir_all(&local_data_dir) {
|
if let Err(e) = std::fs::create_dir_all(&local_data_dir) {
|
||||||
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
show_startup_error(
|
||||||
|
app,
|
||||||
|
&format!("Could not create the local data directory: {e}"),
|
||||||
|
Some(&local_data_dir),
|
||||||
|
);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
// A restore staged by the Data tab is swapped in now, before anything opens the database.
|
// A restore staged by the Data tab is swapped in now, before anything opens the database.
|
||||||
@@ -62,19 +78,32 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
||||||
commands::backup::ApplyOutcome::NothingPending => {}
|
commands::backup::ApplyOutcome::NothingPending => {}
|
||||||
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
||||||
eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
|
eprintln!(
|
||||||
|
"Backup restored; the previous data is in {}",
|
||||||
|
safety_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
commands::backup::ApplyOutcome::Failed { message } => {
|
||||||
|
eprintln!("Backup restore failed: {message}")
|
||||||
}
|
}
|
||||||
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
|
|
||||||
}
|
}
|
||||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
show_startup_error(app, &format!("Could not open the database: {e}"), Some(&data_dir));
|
show_startup_error(
|
||||||
|
app,
|
||||||
|
&format!("Could not open the database: {e}"),
|
||||||
|
Some(&data_dir),
|
||||||
|
);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) {
|
if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) {
|
||||||
show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir));
|
show_startup_error(
|
||||||
|
app,
|
||||||
|
&format!("Could not update stored image paths: {e}"),
|
||||||
|
Some(&data_dir),
|
||||||
|
);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
// Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries.
|
// Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries.
|
||||||
|
|||||||
+43
-12
@@ -224,7 +224,8 @@ fn quantize_colour(img: &mut RgbaImage) {
|
|||||||
|
|
||||||
fn encode_png(img: &DynamicImage) -> Result<Vec<u8>, LogoError> {
|
fn encode_png(img: &DynamicImage) -> Result<Vec<u8>, LogoError> {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let encoder = PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive);
|
let encoder =
|
||||||
|
PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive);
|
||||||
img.write_with_encoder(encoder)
|
img.write_with_encoder(encoder)
|
||||||
.map_err(|e| LogoError::Encode(e.to_string()))?;
|
.map_err(|e| LogoError::Encode(e.to_string()))?;
|
||||||
Ok(buf)
|
Ok(buf)
|
||||||
@@ -360,7 +361,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn decode_png(bytes: &[u8]) -> RgbaImage {
|
fn decode_png(bytes: &[u8]) -> RgbaImage {
|
||||||
image::load_from_memory_with_format(bytes, ImageFormat::Png).unwrap().to_rgba8()
|
image::load_from_memory_with_format(bytes, ImageFormat::Png)
|
||||||
|
.unwrap()
|
||||||
|
.to_rgba8()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -427,7 +430,8 @@ mod tests {
|
|||||||
assert!((38..=42).contains(&m.height), "{}", m.height);
|
assert!((38..=42).contains(&m.height), "{}", m.height);
|
||||||
assert!(out.knockout_png.is_none());
|
assert!(out.knockout_png.is_none());
|
||||||
// The input pixels are not edited: the printed corners are still the logo colour, not transparent.
|
// The input pixels are not edited: the printed corners are still the logo colour, not transparent.
|
||||||
let printed = image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap();
|
let printed =
|
||||||
|
image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap();
|
||||||
assert!(!printed.color().has_alpha());
|
assert!(!printed.color().has_alpha());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +463,10 @@ mod tests {
|
|||||||
// Right magic, broken body.
|
// Right magic, broken body.
|
||||||
let mut truncated = png_bytes(&alpha_logo(20, 20, (2, 2, 5, 5), [0; 3]));
|
let mut truncated = png_bytes(&alpha_logo(20, 20, (2, 2, 5, 5), [0; 3]));
|
||||||
truncated.truncate(30);
|
truncated.truncate(30);
|
||||||
assert!(matches!(process_logo(&truncated), Err(LogoError::Decode(_))));
|
assert!(matches!(
|
||||||
|
process_logo(&truncated),
|
||||||
|
Err(LogoError::Decode(_))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -470,14 +477,23 @@ mod tests {
|
|||||||
|
|
||||||
// A blank image 8193 px wide compresses to a few KB, so it passes the byte cap.
|
// A blank image 8193 px wide compresses to a few KB, so it passes the byte cap.
|
||||||
let wide = RgbaImage::new(8193, 2);
|
let wide = RgbaImage::new(8193, 2);
|
||||||
assert!(matches!(process_logo(&png_bytes(&wide)), Err(LogoError::Dimensions)));
|
assert!(matches!(
|
||||||
|
process_logo(&png_bytes(&wide)),
|
||||||
|
Err(LogoError::Dimensions)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blank_images_have_no_content() {
|
fn blank_images_have_no_content() {
|
||||||
assert!(matches!(process_logo(&png_bytes(&RgbaImage::new(40, 40))), Err(LogoError::Empty)));
|
assert!(matches!(
|
||||||
|
process_logo(&png_bytes(&RgbaImage::new(40, 40))),
|
||||||
|
Err(LogoError::Empty)
|
||||||
|
));
|
||||||
let white = RgbaImage::from_pixel(40, 40, Rgba([255, 255, 255, 255]));
|
let white = RgbaImage::from_pixel(40, 40, Rgba([255, 255, 255, 255]));
|
||||||
assert!(matches!(process_logo(&png_bytes(&white)), Err(LogoError::Empty)));
|
assert!(matches!(
|
||||||
|
process_logo(&png_bytes(&white)),
|
||||||
|
Err(LogoError::Empty)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -508,9 +524,15 @@ mod tests {
|
|||||||
assert!((m.ink_density - 0.52).abs() < 1e-9, "{}", m.ink_density);
|
assert!((m.ink_density - 0.52).abs() < 1e-9, "{}", m.ink_density);
|
||||||
|
|
||||||
let square = alpha_logo(50, 50, (10, 10, 30, 30), [0; 3]);
|
let square = alpha_logo(50, 50, (10, 10, 30, 30), [0; 3]);
|
||||||
assert_eq!(process_logo(&png_bytes(&square)).unwrap().metrics.kind, LogoKind::Mark);
|
assert_eq!(
|
||||||
|
process_logo(&png_bytes(&square)).unwrap().metrics.kind,
|
||||||
|
LogoKind::Mark
|
||||||
|
);
|
||||||
let tall = alpha_logo(50, 100, (10, 10, 12, 40), [0; 3]);
|
let tall = alpha_logo(50, 100, (10, 10, 12, 40), [0; 3]);
|
||||||
assert_eq!(process_logo(&png_bytes(&tall)).unwrap().metrics.kind, LogoKind::Tall);
|
assert_eq!(
|
||||||
|
process_logo(&png_bytes(&tall)).unwrap().metrics.kind,
|
||||||
|
LogoKind::Tall
|
||||||
|
);
|
||||||
assert_eq!(kind_for(2.49), LogoKind::Mark);
|
assert_eq!(kind_for(2.49), LogoKind::Mark);
|
||||||
assert_eq!(kind_for(0.6), LogoKind::Tall);
|
assert_eq!(kind_for(0.6), LogoKind::Tall);
|
||||||
}
|
}
|
||||||
@@ -535,15 +557,24 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sample_logo_is_trimmed_and_small() {
|
fn sample_logo_is_trimmed_and_small() {
|
||||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/pdf/testing/fixtures/sample-wordmark-logo.png");
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../src/pdf/testing/fixtures/sample-wordmark-logo.png");
|
||||||
let bytes = std::fs::read(path).unwrap();
|
let bytes = std::fs::read(path).unwrap();
|
||||||
let out = process_logo(&bytes).unwrap();
|
let out = process_logo(&bytes).unwrap();
|
||||||
let m = &out.metrics;
|
let m = &out.metrics;
|
||||||
assert_eq!((m.source_width, m.source_height), (2172, 724));
|
assert_eq!((m.source_width, m.source_height), (2172, 724));
|
||||||
assert!((5.3..=5.6).contains(&m.aspect), "aspect {}", m.aspect);
|
assert!((5.3..=5.6).contains(&m.aspect), "aspect {}", m.aspect);
|
||||||
assert!((0.50..=0.60).contains(&m.ink_density), "density {}", m.ink_density);
|
assert!(
|
||||||
|
(0.50..=0.60).contains(&m.ink_density),
|
||||||
|
"density {}",
|
||||||
|
m.ink_density
|
||||||
|
);
|
||||||
assert_eq!(m.kind, LogoKind::Wordmark);
|
assert_eq!(m.kind, LogoKind::Wordmark);
|
||||||
assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len());
|
assert!(
|
||||||
|
out.print_png.len() <= 120 * 1024,
|
||||||
|
"print is {} bytes",
|
||||||
|
out.print_png.len()
|
||||||
|
);
|
||||||
assert!(out.knockout_png.is_some());
|
assert!(out.knockout_png.is_some());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ use std::path::{Path, PathBuf};
|
|||||||
const ENV_VAR: &str = "VOICED_SELFTEST_OUT";
|
const ENV_VAR: &str = "VOICED_SELFTEST_OUT";
|
||||||
|
|
||||||
fn configured_path() -> Option<PathBuf> {
|
fn configured_path() -> Option<PathBuf> {
|
||||||
std::env::var_os(ENV_VAR).filter(|v| !v.is_empty()).map(PathBuf::from)
|
std::env::var_os(ENV_VAR)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_report(path: &Path, json: &str) -> Result<(), String> {
|
fn write_report(path: &Path, json: &str) -> Result<(), String> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||||
}
|
}
|
||||||
let mut file = std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?;
|
let mut file =
|
||||||
|
std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?;
|
||||||
file.write_all(json.as_bytes())
|
file.write_all(json.as_bytes())
|
||||||
.and_then(|()| file.sync_all())
|
.and_then(|()| file.sync_all())
|
||||||
.map_err(|e| format!("Could not write the report: {e}"))
|
.map_err(|e| format!("Could not write the report: {e}"))
|
||||||
|
|||||||
Reference in New Issue
Block a user