Add the ERPNext integration foundation (Phase F1)
- New integrations module with an InvoiceSink seam and an ERPNext client built on reqwest with rustls (ring provider only; aws-lc is not in the dependency graph). Token auth, timeouts, retry with backoff on 429, 5xx and timeouts (POSTs only when idempotent), optional extra CA, and a rule that plain http is allowed only for localhost-style hosts. - Error extraction for Frappe v1 and v2 bodies, version and India Compliance discovery, live option loaders, and a connection test that returns warnings instead of failing. - Pure, snapshot-tested mapping from a stored invoice to a Sales Invoice (CGST+SGST, IGST, unregistered, discount, code-less rows, mirrored and series naming) that refuses to produce a payload whose totals differ from Voiced's. - Migration M10 adds erpnext_config (the API secret is never returned to the webview), erpnext_sync and the nullable ERPNext link columns. - Commands to get and save the config, test the connection and load options, with typed wrappers. No UI and no push yet. The API secret is stored in the SQLite file, so backups contain it. Nothing has run against a live ERPNext yet.
This commit is contained in:
+132
-1
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||
|
||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||
pub(crate) const LATEST_VERSION: i64 = 9;
|
||||
pub(crate) const LATEST_VERSION: i64 = 10;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
@@ -270,6 +270,61 @@ const M9: &str = r#"
|
||||
ALTER TABLE app_settings ADD COLUMN auto_backup INTEGER NOT NULL DEFAULT 1;
|
||||
"#;
|
||||
|
||||
/// Version 10: ERPNext integration. `erpnext_config` is a single row (inserted here, so reading it is a plain SELECT)
|
||||
/// holding the connection, the company/account/master mapping and the toggles. `api_secret` is stored here and is never
|
||||
/// returned to the webview. `erpnext_sync` tracks what was pushed per invoice; issued invoices themselves are untouched.
|
||||
/// The remote names on clients, payments and item presets are nullable: NULL means "not linked yet".
|
||||
const M10: &str = r#"
|
||||
CREATE TABLE erpnext_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
api_secret TEXT NOT NULL DEFAULT '',
|
||||
extra_ca_pem TEXT NOT NULL DEFAULT '',
|
||||
company TEXT NOT NULL DEFAULT '',
|
||||
company_address TEXT NOT NULL DEFAULT '',
|
||||
naming_mode TEXT NOT NULL DEFAULT 'mirror' CHECK (naming_mode IN ('mirror', 'series')),
|
||||
naming_series TEXT NOT NULL DEFAULT '',
|
||||
income_account TEXT NOT NULL DEFAULT '',
|
||||
cost_center TEXT NOT NULL DEFAULT '',
|
||||
cgst_account TEXT NOT NULL DEFAULT '',
|
||||
sgst_account TEXT NOT NULL DEFAULT '',
|
||||
utgst_account TEXT NOT NULL DEFAULT '',
|
||||
igst_account TEXT NOT NULL DEFAULT '',
|
||||
tax_template_intra TEXT NOT NULL DEFAULT '',
|
||||
tax_template_inter TEXT NOT NULL DEFAULT '',
|
||||
payment_bank_account TEXT NOT NULL DEFAULT '',
|
||||
tds_account TEXT NOT NULL DEFAULT '',
|
||||
default_item_code TEXT NOT NULL DEFAULT '',
|
||||
uom_map TEXT NOT NULL DEFAULT '{"second":"Second","minute":"Minute","hour":"Hour","session":"Nos","unit":"Nos"}',
|
||||
customer_group TEXT NOT NULL DEFAULT '',
|
||||
territory TEXT NOT NULL DEFAULT '',
|
||||
selling_price_list TEXT NOT NULL DEFAULT '',
|
||||
submit_on_push INTEGER NOT NULL DEFAULT 0,
|
||||
attach_pdf INTEGER NOT NULL DEFAULT 1,
|
||||
auto_push_on_issue INTEGER NOT NULL DEFAULT 0,
|
||||
create_missing_customers INTEGER NOT NULL DEFAULT 1,
|
||||
last_detect_result TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT
|
||||
);
|
||||
INSERT INTO erpnext_config (id) VALUES (1);
|
||||
|
||||
CREATE TABLE erpnext_sync (
|
||||
invoice_id INTEGER PRIMARY KEY REFERENCES invoices(id),
|
||||
remote_name TEXT NOT NULL DEFAULT '',
|
||||
remote_docstatus INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'error')),
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
payload_hash TEXT NOT NULL DEFAULT '',
|
||||
synced_at TEXT
|
||||
);
|
||||
|
||||
ALTER TABLE clients ADD COLUMN erpnext_customer TEXT;
|
||||
ALTER TABLE clients ADD COLUMN erpnext_address TEXT;
|
||||
ALTER TABLE payments ADD COLUMN erpnext_payment_entry TEXT;
|
||||
ALTER TABLE item_presets ADD COLUMN erpnext_item_code TEXT;
|
||||
"#;
|
||||
|
||||
fn migrations() -> Migrations<'static> {
|
||||
Migrations::new(vec![
|
||||
M::up(SCHEMA),
|
||||
@@ -281,6 +336,7 @@ fn migrations() -> Migrations<'static> {
|
||||
M::up(M7),
|
||||
M::up(M8),
|
||||
M::up(M9),
|
||||
M::up(M10),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -786,6 +842,81 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
assert_eq!(rows[2].5, "unregistered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_fresh_database_has_the_erpnext_tables_and_a_default_config_row() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let (naming, submit, attach, auto_push, create_customers, secret, uom): (String, i64, i64, i64, i64, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT naming_mode, submit_on_push, attach_pdf, auto_push_on_issue, create_missing_customers, api_secret, uom_map
|
||||
FROM erpnext_config WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((naming.as_str(), submit, attach, auto_push, create_customers), ("mirror", 0, 1, 0, 1));
|
||||
assert_eq!(secret, "");
|
||||
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();
|
||||
assert_eq!(rows, 1);
|
||||
// Single row only.
|
||||
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")
|
||||
.unwrap();
|
||||
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients").unwrap();
|
||||
conn.prepare("SELECT erpnext_payment_entry FROM payments").unwrap();
|
||||
conn.prepare("SELECT erpnext_item_code FROM item_presets").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
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),
|
||||
];
|
||||
Migrations::new(v9).to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 9);
|
||||
conn.execute("INSERT INTO clients (name, gstin, created_at) VALUES ('Acme', '29ABCDE1234F1Z5', 'now')", []).unwrap();
|
||||
conn.execute("INSERT INTO item_presets (description, rate_paise, created_at) VALUES ('Design', 500000, 'now')", []).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO payments (invoice_id, paid_on, amount_paise, mode, created_at) VALUES (1, '2026-04-10', 12345, 'upi', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrations().to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 10);
|
||||
|
||||
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) = conn
|
||||
.query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), gstin.as_str(), customer, address), ("Acme", "29ABCDE1234F1Z5", None, None));
|
||||
let (desc, rate, code): (String, i64, Option<String>) = conn
|
||||
.query_row("SELECT description, rate_paise, erpnext_item_code FROM item_presets", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
|
||||
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)?)))
|
||||
.unwrap();
|
||||
assert_eq!((paid, entry), (12345, None));
|
||||
let number: String = conn.query_row("SELECT number FROM invoices", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(number, "INV/2026-001");
|
||||
|
||||
// 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();
|
||||
assert_eq!(enabled, 1);
|
||||
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());
|
||||
}
|
||||
|
||||
fn build_v1_database(path: &Path) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user