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
+58 -7
View File
@@ -2,12 +2,12 @@
//! 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.
// Parts of the sink API are only used by the later UI step.
#![allow(dead_code)]
pub mod erpnext;
use crate::models::Invoice;
use serde::Serialize;
use std::future::Future;
/// What a sink reports after accepting an invoice.
@@ -19,12 +19,55 @@ pub struct PushedInvoice {
pub remote_docstatus: i64,
/// False when the remote already had this invoice (an idempotent re-push).
pub created: bool,
/// Non-fatal notes, e.g. "pushed without the PDF because the invoice is not archived".
pub warnings: Vec<String>,
}
pub struct PushRequest<'a> {
pub invoice: &'a Invoice,
/// Submit on the remote system instead of leaving a draft for review.
pub submit: bool,
pub struct PushRequest {
pub invoice_id: i64,
/// Submit on the remote system instead of leaving a draft for review; `None` follows the sink's setting.
pub submit: Option<bool>,
}
pub struct PaymentRequest {
pub payment_id: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushedPayment {
pub remote_name: String,
/// False when the payment had already been sent (an idempotent re-push).
pub created: bool,
}
/// The locally recorded sync state of one invoice. `status` is `none` until something was pushed, then
/// `synced`, `error` or `conflict`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncStatus {
pub invoice_id: i64,
pub status: String,
pub remote_name: String,
/// 0 draft, 1 submitted.
pub remote_docstatus: i64,
pub last_error: String,
pub synced_at: Option<String>,
/// The archived PDF was uploaded to the remote document.
pub attached: bool,
}
impl SyncStatus {
pub fn none(invoice_id: i64) -> Self {
SyncStatus {
invoice_id,
status: "none".into(),
remote_name: String::new(),
remote_docstatus: 0,
last_error: String::new(),
synced_at: None,
attached: false,
}
}
}
pub trait InvoiceSink: Send + Sync {
@@ -33,6 +76,14 @@ pub trait InvoiceSink: Send + Sync {
fn push_invoice<'a>(
&'a self,
request: PushRequest<'a>,
request: PushRequest,
) -> impl Future<Output = Result<PushedInvoice, String>> + Send + 'a;
fn push_payment<'a>(
&'a self,
request: PaymentRequest,
) -> impl Future<Output = Result<PushedPayment, String>> + Send + 'a;
/// The locally recorded state; never touches the network.
fn status(&self, invoice_id: i64) -> Result<SyncStatus, String>;
}