Add the ERPNext settings UI, History and detail controls, and voiced.invoice.v1 export (Phase F3)

- Settings gets an Integrations tab: connection fields with a write-only API secret, a CA PEM option, Test connection with versions and warnings, live mapping lists loaded after a test, naming mode, toggles and its own Save.
- Pushing for a GST-registered vendor is refused until a connection test result is stored. The UI shows the reason and disables Send.
- History shows an ERPNext status column from one bulk call, a row Send and Open in ERPNext, and a bulk Send selected with a draft-or-submit confirm step and per-row results that never abort the batch. The detail view gets an ERPNext panel and per-payment send. All of it is hidden or disabled when the integration is not configured.
- Optional auto-push after issue runs in the background and never blocks or fails the issue.
- Generic export: a deterministic voiced.invoice.v1 JSON (and CSV line items) for selected invoices, documented in docs/voiced-invoice-v1.md with a golden test. Money is integer paise plus a decimal string.

The new screens have not been run in a webview yet. The ERPNext Payment Entry deduction fields still need a live check.
This commit is contained in:
2026-10-04 20:57:31 +05:30
parent 15f942165f
commit 3591bb9f7d
24 changed files with 2855 additions and 11 deletions
+5 -1
View File
@@ -30,6 +30,9 @@ pub struct Payment {
pub reference: String,
pub notes: String,
pub created_at: String,
/// Name of the Payment Entry on ERPNext once this payment was sent; `None` until then.
#[serde(default)]
pub erpnext_payment_entry: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -127,10 +130,11 @@ fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
reference: r.get(6)?,
notes: r.get(7)?,
created_at: r.get(8)?,
erpnext_payment_entry: r.get::<_, Option<String>>(9)?.filter(|e| !e.trim().is_empty()),
})
}
const COLS: &str = "id, invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at";
const COLS: &str = "id, invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at, erpnext_payment_entry";
pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result<Payment, String> {
let db = |e: rusqlite::Error| e.to_string();
@@ -323,6 +323,14 @@ fn load_item_codes(conn: &Connection, inv: &Invoice) -> Result<Vec<Option<String
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).
fn has_detect_result(raw: &str) -> bool {
match serde_json::from_str::<Value>(raw) {
Ok(Value::Object(m)) => !m.is_empty(),
_ => false,
}
}
fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (String, ErpError)> {
let conn = db
.lock()
@@ -346,6 +354,11 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?;
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot)
.ok_or_else(|| fail(pre(format!("Invoice {number} has no supplier details recorded, so it cannot be sent."))))?;
// A registered vendor's invoices go out with India Compliance fields, and those depend on what the
// connection test detected, so refuse until the test has run once.
if vendor.registered && !has_detect_result(&cfg.last_detect_result) {
return Err(fail(pre("Run Test connection first (Settings, Integrations): Voiced has not yet checked this ERPNext site.")));
}
let client = load_client(&conn, &invoice).map_err(|e| fail(pre(e)))?;
let item_codes = load_item_codes(&conn, &invoice).map_err(|e| fail(pre(e)))?;
let sync = load_sync(&conn, invoice_id).map_err(|e| fail(pre(e)))?;
@@ -518,6 +518,45 @@ async fn cancelled_and_draft_invoices_are_refused_before_any_request() {
assert!(fx.sync_row(inv.id).is_none() && fx.sync_row(draft_id).is_none());
}
#[tokio::test]
async fn a_registered_vendor_must_run_the_connection_test_before_pushing() {
let fx = Fx::new(|c| c.last_detect_result = String::new());
let inv = fx.issue("Client Ltd");
let mut routes = master_routes();
routes.push(mirror_create(&inv.number, 0, inv.total));
let m = mock(routes).await;
let r = fx.push(&m, inv.id, Some(false)).await;
assert!(!r.ok && r.status == "refused" && r.error_kind == Some(ErrorKind::Precondition), "{r:?}");
assert!(r.error.as_deref().unwrap().contains("Run Test connection first"), "{r:?}");
assert!(m.requests.lock().unwrap().is_empty());
assert!(fx.sync_row(inv.id).is_none());
// `{}` and `null` are as good as nothing; a stored result unlocks the push.
assert!(!has_detect_result("") && !has_detect_result("{}") && !has_detect_result("null"));
fx.db.lock().unwrap().execute("UPDATE erpnext_config SET last_detect_result = '{\"indiaCompliance\":false}'", []).unwrap();
let r = fx.push(&m, inv.id, Some(false)).await;
assert!(r.ok, "{r:?}");
}
#[tokio::test]
async fn an_unregistered_vendor_needs_no_connection_test() {
let fx = Fx::new(|c| c.last_detect_result = String::new());
let inv = fx.issue("Other Co");
// Only the frozen supplier snapshot decides whether the invoice counts as a registered vendor's.
fx.db
.lock()
.unwrap()
.execute("UPDATE invoices SET vendor_snapshot = replace(vendor_snapshot, '\"gstRegistration\":\"regular\"', '\"gstRegistration\":\"unregistered\"')", [])
.unwrap();
assert!(!Vendor::from_snapshot(&get_invoice_impl(&fx.db.lock().unwrap(), inv.id).unwrap().vendor_snapshot).unwrap().registered);
let mut routes = master_routes();
routes.push(mirror_create(&inv.number, 0, inv.total));
let m = mock(routes).await;
let r = fx.push(&m, inv.id, Some(false)).await;
assert!(r.status != "refused", "{r:?}");
}
#[tokio::test]
async fn an_unarchived_invoice_is_pushed_without_a_pdf_and_attached_by_a_later_push() {
let fx = Fx::new(|_| {});