/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */ import type { ErpnextConfig, ErpnextConfigInput, ErpnextConnectionTest, ErpnextOptionItem, ErpnextOptions, ErpnextPaymentPushResult, ErpnextPushResult, ErpnextSyncStatus, ErpnextWarning, } from "./erpnext"; export type TagColor = "red" | "green" | "blue" | "gray" | "purple" | "cool-gray" | "warm-gray"; export type NoticeKind = "success" | "info" | "warning" | "error"; export const SETTINGS_LINK_TEXT = "Settings, Integrations"; // ---- configuration state ---- /** Enough saved to try a push: address, key and a stored secret. */ export function isConfigured(cfg: Pick | null | undefined): boolean { return Boolean(cfg && cfg.baseUrl.trim() && cfg.apiKey.trim() && cfg.apiSecretSet); } /** True when a connection test result is stored (the backend refuses a registered vendor's push without one). */ export function hasDetectResult(result: ErpnextConnectionTest | null | undefined): boolean { return Boolean(result && typeof result === "object" && Object.keys(result).length > 0); } export const NOT_CONFIGURED_REASON = "ERPNext is not set up. Enter the address, API key and secret in Settings, Integrations."; export const RUN_TEST_REASON = "Run Test connection first (Settings, Integrations)."; /** * Why an invoice cannot be sent right now, or null. The backend applies the same registered-vendor rule using the * supplier snapshot frozen on the invoice; the current GST registration setting is the proxy here. */ export function pushDisabledReason(opts: { cfg: ErpnextConfig | null; vendorRegistered: boolean; invoiceStatus?: string; }): string | null { if (!isConfigured(opts.cfg)) return NOT_CONFIGURED_REASON; if (opts.invoiceStatus !== undefined && opts.invoiceStatus !== "issued") { return opts.invoiceStatus === "cancelled" ? "Cancelled invoices are never sent to ERPNext." : "Only issued invoices are sent to ERPNext."; } if (opts.vendorRegistered && !hasDetectResult(opts.cfg?.lastDetectResult)) return RUN_TEST_REASON; return null; } /** A payment can be posted only against a submitted invoice, once. */ export function paymentPushDisabledReason(opts: { cfg: ErpnextConfig | null; sync: ErpnextSyncStatus | undefined; entry: string | null | undefined; }): string | null { if (!isConfigured(opts.cfg)) return NOT_CONFIGURED_REASON; if (opts.entry) return `Already sent as ${opts.entry}.`; if (!opts.sync || opts.sync.status !== "synced") return "Send the invoice to ERPNext first."; if (opts.sync.remoteDocstatus !== 1) return "Submit the invoice in ERPNext first."; return null; } // ---- sync status ---- export interface SyncTag { label: string; type: TagColor; /** Hover text: the stored error for a failure. */ tooltip?: string; } export function syncTag(s: ErpnextSyncStatus | undefined): SyncTag { if (!s || s.status === "none") return { label: "Not sent", type: "gray" }; switch (s.status) { case "synced": return s.remoteDocstatus === 1 ? { label: "Submitted", type: "green", tooltip: s.remoteName || undefined } : { label: "Draft in ERPNext", type: "blue", tooltip: s.remoteName || undefined }; case "error": return { label: "Error", type: "red", tooltip: s.lastError || "The last send failed." }; case "conflict": return { label: "Conflict", type: "purple", tooltip: s.lastError || "ERPNext already has a different document with this name." }; case "pending": return { label: "Sending", type: "warm-gray" }; default: return { label: "Not sent", type: "gray" }; } } export function indexSyncStatuses(list: ErpnextSyncStatus[]): Map { return new Map(list.map((s) => [s.invoiceId, s])); } /** Rows that have an ERPNext document to open. */ export const canOpenInErpnext = (s: ErpnextSyncStatus | undefined): boolean => Boolean(s && s.remoteName); /** Stored sync time (RFC 3339) for display; the raw text if it cannot be parsed, blank if there is none. */ export function formatSyncedAt(iso: string | null | undefined): string { if (!iso) return ""; const d = new Date(iso); return Number.isNaN(d.getTime()) ? iso : d.toLocaleString("en-GB", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }); } /** From a selection: the issued invoices that can be sent, and how many were left out (cancelled or draft). */ export function sendableSelection(rows: T[]): { send: T[]; skipped: number } { const send = rows.filter((r) => r.status === "issued"); return { send, skipped: rows.length - send.length }; } // ---- push results ---- export interface Notice { kind: NoticeKind; title: string; subtitle?: string; } const docState = (docstatus: number) => (docstatus === 1 ? "submitted" : "draft"); /** One push outcome as a toast. Warnings turn a success into a warning toast so they are read. */ export function describePush(r: ErpnextPushResult): Notice { if (r.ok) { const where = `${r.remoteName} (${docState(r.remoteDocstatus)})`; if (r.warnings.length > 0) { return { kind: "warning", title: `Invoice ${r.number} sent to ERPNext with notes`, subtitle: [where, ...r.warnings].join(" ") }; } if (r.noOp) return { kind: "info", title: `Invoice ${r.number} is already up to date in ERPNext`, subtitle: where }; return { kind: "success", title: `Invoice ${r.number} sent to ERPNext`, subtitle: where }; } if (r.status === "refused" || r.errorKind === "precondition" || r.errorKind === "config") { return { kind: "warning", title: `Invoice ${r.number || r.invoiceId} was not sent`, subtitle: r.error ?? undefined }; } if (r.status === "conflict") { return { kind: "error", title: `Invoice ${r.number} conflicts with ERPNext`, subtitle: r.error ?? undefined }; } return { kind: "error", title: `Could not send invoice ${r.number || r.invoiceId}`, subtitle: r.error ?? undefined }; } export function describePaymentPush(r: ErpnextPaymentPushResult): Notice { if (r.ok) { return r.alreadySynced ? { kind: "info", title: "Payment was already sent", subtitle: r.entryName ?? undefined } : { kind: "success", title: "Payment sent to ERPNext", subtitle: r.entryName ?? undefined }; } return { kind: "error", title: "Could not send the payment", subtitle: r.error ?? undefined }; } /** A rejected invoke (for example "ERPNext is not set up yet") turned into a result row, so a batch carries on. */ export function failedPush(invoiceId: number, number: string, error: unknown): ErpnextPushResult { return { invoiceId, number, ok: false, status: "error", remoteName: "", remoteDocstatus: 0, created: false, noOp: false, attached: false, error: error instanceof Error ? error.message : String(error), errorKind: "other", warnings: [], }; } export interface BatchSummary { total: number; sent: number; upToDate: number; failed: number; withNotes: number; kind: NoticeKind; title: string; } export function summarizeBatch(results: ErpnextPushResult[]): BatchSummary { const failed = results.filter((r) => !r.ok).length; const upToDate = results.filter((r) => r.ok && r.noOp && r.warnings.length === 0).length; const sent = results.filter((r) => r.ok).length - upToDate; const withNotes = results.filter((r) => r.ok && r.warnings.length > 0).length; const kind: NoticeKind = failed > 0 ? (failed === results.length ? "error" : "warning") : withNotes > 0 ? "warning" : "success"; const parts = [`${sent} sent`]; if (upToDate > 0) parts.push(`${upToDate} already up to date`); if (failed > 0) parts.push(`${failed} failed`); return { total: results.length, sent, upToDate, failed, withNotes, kind, title: parts.join(", ") }; } // ---- auto-push after issue ---- export interface AutoPushDeps { getConfig(): Promise; push(id: number, submit?: boolean): Promise; } /** * Pushes a just-issued invoice when "send on issue" is on. Never throws and never blocks the issue: anything that * goes wrong comes back as a notice. Returns null when nothing was attempted (off, or not set up). */ export async function autoPushIssued( deps: AutoPushDeps, invoice: { id: number; number: string }, vendorRegistered: boolean, ): Promise { let cfg: ErpnextConfig; try { cfg = await deps.getConfig(); } catch { return null; } if (!cfg.autoPushOnIssue || !isConfigured(cfg)) return null; const blocked = pushDisabledReason({ cfg, vendorRegistered }); if (blocked) return { kind: "warning", title: `Invoice ${invoice.number} was not sent to ERPNext`, subtitle: blocked }; try { return describePush(await deps.push(invoice.id)); } catch (e) { return describePush(failedPush(invoice.id, invoice.number, e)); } } // ---- connection test display ---- const ERROR_CODES = new Set([ "company_missing", "company_address_missing", "income_account_missing", "tax_accounts_missing", "naming_series_missing", "naming_series_too_long", "number_invalid", "mirror_unsupported", "gst_accounts_not_configured", ]); const INFO_CODES = new Set(["versions_unknown", "gst_settings_unreadable"]); /** How loudly a test warning is shown: the ones that would make invoices land wrong are errors. */ export function warningSeverity(code: string): "error" | "warning" | "info" { if (ERROR_CODES.has(code)) return "error"; if (INFO_CODES.has(code)) return "info"; return "warning"; } export function sortWarnings(list: ErpnextWarning[]): ErpnextWarning[] { const rank = { error: 0, warning: 1, info: 2 } as const; return [...list].sort((a, b) => rank[warningSeverity(a.code)] - rank[warningSeverity(b.code)]); } export function versionsLine(test: ErpnextConnectionTest): string { const v = test.versions; const parts = [ `Frappe ${v.frappe ?? "unknown"}`, `ERPNext ${v.erpnext ?? "unknown"}`, test.indiaCompliance ? `India Compliance ${v.indiaCompliance ?? "installed"}` : "India Compliance not detected", ]; return parts.join(" ยท "); } // ---- settings form ---- export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const; export const DEFAULT_UOM_MAP: Record = { second: "Second", minute: "Minute", hour: "Hour", session: "Nos", unit: "Nos", }; export function withDefaultUoms(map: Record | undefined): Record { const out: Record = { ...DEFAULT_UOM_MAP }; for (const [k, v] of Object.entries(map ?? {})) if (v.trim()) out[k] = v; return out; } /** What the form edits: the saved config minus the write-only fields, plus the secret being typed. */ export function toInput(cfg: ErpnextConfig): ErpnextConfigInput { const { apiSecretSet: _set, lastDetectResult: _last, ...rest } = cfg; void _set; void _last; return { ...rest, uomMap: withDefaultUoms(rest.uomMap), apiSecret: "", clearSecret: false }; } /** Mirror mode needs a Frappe that keeps a posted name; an untested site defaults to it (the backend default). */ export function mirrorSupported(test: ErpnextConnectionTest | null): boolean { return test ? test.features.v2Naming : true; } export function isLocalDevHost(host: string): boolean { const h = host.replace(/\.$/, "").toLowerCase(); return h === "localhost" || h === "127.0.0.1" || h.endsWith(".localhost") || h.endsWith(".test"); } /** Same rules as the backend's address check, so a bad address is caught before the round trip. */ export function baseUrlProblem(raw: string): string | null { const text = raw.trim(); if (!text) return "Enter the address of your ERPNext site."; const withScheme = text.includes("://") ? text : `${isLocalDevHost(text.split(/[/:]/)[0] ?? "") ? "http" : "https"}://${text}`; let url: URL; try { url = new URL(withScheme); } catch { return "This is not a valid address, e.g. https://erp.example.com"; } if (url.protocol !== "https:" && url.protocol !== "http:") return "Use an https:// address."; if (url.protocol === "http:" && !isLocalDevHost(url.hostname)) { return "Use an https:// address. Plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test."; } if (url.username || url.password) return "Do not put credentials in the address; use the API key and secret fields."; return null; } export interface FormProblems { baseUrl?: string; apiKey?: string; apiSecret?: string; company?: string; namingSeries?: string; namingMode?: string; } /** Problems that stop a Test connection (address, key, secret). */ export function connectionProblems(form: ErpnextConfigInput, secretStored: boolean): FormProblems { const p: FormProblems = {}; const url = baseUrlProblem(form.baseUrl); if (url) p.baseUrl = url; if (!form.apiKey.trim()) p.apiKey = "Enter the API key."; if (!form.apiSecret.trim() && (form.clearSecret || !secretStored)) p.apiSecret = "Enter the API secret."; return p; } /** Problems that stop a Save. An empty form (nothing set up) saves fine; a half-filled one does not. */ export function validateConfigForm(form: ErpnextConfigInput, secretStored: boolean, test: ErpnextConnectionTest | null): FormProblems { const touched = form.baseUrl.trim() || form.apiKey.trim() || form.apiSecret.trim() || secretStored; if (!touched) return {}; const p = connectionProblems(form, secretStored); if (form.namingMode === "series" && !form.namingSeries.trim()) p.namingSeries = "Choose or enter the naming series."; if (form.namingMode === "mirror" && !mirrorSupported(test)) { p.namingMode = "This ERPNext version does not keep a posted invoice number. Use ERPNext series."; } return p; } export const hasProblems = (p: FormProblems): boolean => Object.keys(p).length > 0; /** Options for a dropdown, with the saved value kept visible when the loaded list does not contain it. */ export function withStoredValue(items: ErpnextOptionItem[], stored: string): ErpnextOptionItem[] { const value = stored.trim(); if (!value || items.some((i) => i.name === value)) return items; return [{ name: value, label: value, detail: "saved value, not in the loaded list" }, ...items]; } export const optionText = (i: ErpnextOptionItem): string => (i.detail ? `${i.label} (${i.detail})` : i.label); export const OPTION_LIST_LABELS: Record = { companies: "Companies", companyAddresses: "Company addresses", incomeAccounts: "Income accounts", taxAccounts: "Tax accounts", taxTemplates: "Tax templates", namingSeries: "Naming series", itemGroups: "Item groups", uoms: "Units of measure", costCenters: "Cost centers", priceLists: "Price lists", customerGroups: "Customer groups", territories: "Territories", }; /** Per-list load errors keyed by list name; the other lists stay usable. */ export function optionErrors(options: ErpnextOptions | null): Map { return new Map((options?.errors ?? []).map((e) => [e.list, e.message])); } export const emptyOptions = (): ErpnextOptions => ({ companies: [], companyAddresses: [], incomeAccounts: [], taxAccounts: [], taxTemplates: [], namingSeries: [], itemGroups: [], uoms: [], costCenters: [], priceLists: [], customerGroups: [], territories: [], errors: [], });