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 e0ba578541
commit 28f922f836
12 changed files with 2356 additions and 18 deletions
+64
View File
@@ -88,3 +88,67 @@ export interface ErpnextOptions {
/** Lists that failed to load; the others are still filled. */
errors: { list: string; message: string }[];
}
export type ErpnextErrorKind =
| "auth"
| "permission"
| "not_found"
| "duplicate"
| "validation"
| "rate_limit"
| "server"
| "timeout"
| "network"
| "config"
| "protocol"
| "conflict"
| "precondition"
| "other";
/** `refused`: nothing was sent and nothing was recorded (a draft or cancelled invoice). */
export type ErpnextPushStatus = "synced" | "error" | "conflict" | "refused";
export interface ErpnextPushResult {
invoiceId: number;
number: string;
ok: boolean;
status: ErpnextPushStatus;
remoteName: string;
/** 0 draft, 1 submitted. */
remoteDocstatus: number;
/** Created by this call, not found already there. */
created: boolean;
/** Already synced with the same payload; nothing was done. */
noOp: boolean;
/** The archived PDF is attached on the remote document. */
attached: boolean;
error: string | null;
errorKind: ErpnextErrorKind | null;
/** Non-fatal notes, e.g. sent without the PDF because the invoice is not archived. */
warnings: string[];
}
export interface ErpnextPaymentPushResult {
paymentId: number;
invoiceId: number;
ok: boolean;
entryName: string | null;
/** Sent earlier; nothing was posted. */
alreadySynced: boolean;
error: string | null;
errorKind: ErpnextErrorKind | null;
}
/** `none` until something was pushed. */
export type ErpnextSyncState = "none" | "pending" | "synced" | "error" | "conflict";
export interface ErpnextSyncStatus {
invoiceId: number;
status: ErpnextSyncState;
remoteName: string;
/** 0 draft, 1 submitted. */
remoteDocstatus: number;
lastError: string;
syncedAt: string | null;
attached: boolean;
}