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
+16
View File
@@ -19,6 +19,9 @@ import type {
ErpnextConfigInput,
ErpnextConnectionTest,
ErpnextOptions,
ErpnextPaymentPushResult,
ErpnextPushResult,
ErpnextSyncStatus,
} from "./erpnext";
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
@@ -126,4 +129,17 @@ export const api = {
erpnextTestConnection: (config: ErpnextConfigInput) =>
invoke<ErpnextConnectionTest>("erpnext_test_connection", { config }),
erpnextLoadOptions: (config: ErpnextConfigInput) => invoke<ErpnextOptions>("erpnext_load_options", { config }),
/** `submit` omitted follows the "submit on push" setting. A failed push resolves with `ok: false`. */
erpnextPushInvoice: (id: number, submit?: boolean) =>
invoke<ErpnextPushResult>("erpnext_push_invoice", { id, submit: submit ?? null }),
/** One result per distinct id, in order; a failing row never stops the rest. */
erpnextPushInvoices: (ids: number[], submit?: boolean) =>
invoke<ErpnextPushResult[]>("erpnext_push_invoices", { ids, submit: submit ?? null }),
erpnextPushPayment: (paymentId: number) =>
invoke<ErpnextPaymentPushResult>("erpnext_push_payment", { paymentId }),
erpnextSyncStatus: (id: number) => invoke<ErpnextSyncStatus>("erpnext_sync_status", { id }),
/** Every invoice that has a sync row (invoices never pushed are absent). */
erpnextSyncStatuses: () => invoke<ErpnextSyncStatus[]>("erpnext_sync_statuses"),
/** `<base>/app/sales-invoice/<name>`; rejects when the invoice was never sent. */
erpnextOpenUrl: (id: number) => invoke<string>("erpnext_open_url", { id }),
};