Add ERPNext push logic for invoices and payments (Phase F2)

- ErpnextSink implements InvoiceSink. Pushing an issued invoice ensures the Customer and Address (read-back of the returned name, local PIN/state/GSTIN checks), creates the Sales Invoice, optionally submits it in a second call, and attaches the archived PDF as a private file once.
- Idempotent: a mirrored-name 409 is compared by grand total (same total records as synced, different total is a conflict and never overwrites); series mode looks up an existing document by its remarks before re-posting; an identical re-push sends nothing.
- Create and submit are separate calls so a draft survives a failed submit. A failed push is recorded in erpnext_sync with a readable message. Bulk push never aborts on one failing row.
- Payment push requires a submitted parent, maps TDS to Payment Entry deductions and is idempotent through the stored entry name.
- Migration M11 rebuilds erpnext_sync to allow a conflict status and adds attachment_sha256.
- Commands for single and bulk push, payment push, sync status and the open-in-ERPNext URL, with typed wrappers. No UI yet.

Tested against a mock server only. The Payment Entry deductions fields, the series-mode submit route and the payment amount semantics need a live ERPNext check.
This commit is contained in:
2026-10-04 20:33:45 +05:30
parent 86b4d436fa
commit d3b5fbb1b8
12 changed files with 2356 additions and 18 deletions
+63 -2
View File
@@ -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 = 10;
pub(crate) const LATEST_VERSION: i64 = 11;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
@@ -325,6 +325,26 @@ ALTER TABLE payments ADD COLUMN erpnext_payment_entry TEXT;
ALTER TABLE item_presets ADD COLUMN erpnext_item_code TEXT;
"#;
/// Version 11: ERPNext push state. `erpnext_sync.status` gains `conflict` (the remote document exists with a different
/// total; we never overwrite it) and `attachment_sha256` records which archived PDF was uploaded so it is never attached
/// twice. SQLite cannot alter a CHECK constraint, so the table is rebuilt; M10 rows are copied over.
const M11: &str = r#"
CREATE TABLE erpnext_sync_new (
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', 'conflict')),
last_error TEXT NOT NULL DEFAULT '',
payload_hash TEXT NOT NULL DEFAULT '',
synced_at TEXT,
attachment_sha256 TEXT NOT NULL DEFAULT ''
);
INSERT INTO erpnext_sync_new (invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at)
SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync;
DROP TABLE erpnext_sync;
ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync;
"#;
fn migrations() -> Migrations<'static> {
Migrations::new(vec![
M::up(SCHEMA),
@@ -337,6 +357,7 @@ fn migrations() -> Migrations<'static> {
M::up(M8),
M::up(M9),
M::up(M10),
M::up(M11),
])
}
@@ -889,7 +910,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.unwrap();
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 10);
assert_eq!(user_version(&conn), LATEST_VERSION);
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) = conn
.query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| {
@@ -917,6 +938,46 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", []).is_err());
}
#[test]
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
let mut conn = Connection::open_in_memory().unwrap();
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),
];
Migrations::new(v10).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 10);
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 erpnext_sync (invoice_id, remote_name, remote_docstatus, status, payload_hash, synced_at)
VALUES (1, 'INV/2026-001', 1, 'synced', 'abc', '2026-04-02T00:00:00Z')",
[],
)
.unwrap();
assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err());
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 11);
let row: (String, i64, String, String, String, Option<String>) = conn
.query_row(
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
)
.unwrap();
assert_eq!(
row,
("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();
assert!(conn.execute("UPDATE erpnext_sync SET status = 'bogus'", []).is_err());
// The unique key still guards one row per invoice.
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", []).is_err());
}
fn build_v1_database(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(V1_SCHEMA).unwrap();