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:
2026-10-04 19:50:24 +05:30
parent 92f952b136
commit 86b4d436fa
16 changed files with 4254 additions and 9 deletions
+38
View File
@@ -0,0 +1,38 @@
//! Outbound integrations. `InvoiceSink` is the seam between Voiced and a system that receives issued
//! invoices (ERPNext now; a webhook, Zoho or a Tally import later). Everything target-specific lives in a
//! submodule; the rest of the app only talks to this trait.
// The push commands that use the sink arrive in the next step.
#![allow(dead_code)]
pub mod erpnext;
use crate::models::Invoice;
use std::future::Future;
/// What a sink reports after accepting an invoice.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushedInvoice {
/// The document name on the remote system.
pub remote_name: String,
/// 0 draft, 1 submitted.
pub remote_docstatus: i64,
/// False when the remote already had this invoice (an idempotent re-push).
pub created: bool,
}
pub struct PushRequest<'a> {
pub invoice: &'a Invoice,
/// Submit on the remote system instead of leaving a draft for review.
pub submit: bool,
}
pub trait InvoiceSink: Send + Sync {
/// Stable identifier used in the sync table (`erpnext`, ...).
fn id(&self) -> &'static str;
fn push_invoice<'a>(
&'a self,
request: PushRequest<'a>,
) -> impl Future<Output = Result<PushedInvoice, String>> + Send + 'a;
}