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:
+63
-2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user