Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.
- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.
Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
|
|
import { UNITS } from "./units";
|
|
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<ErpnextConfig, "baseUrl" | "apiKey" | "apiSecretSet"> | 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<number, ErpnextSyncStatus> {
|
|
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<T extends { status: string }>(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<ErpnextConfig>;
|
|
push(id: number, submit?: boolean): Promise<ErpnextPushResult>;
|
|
}
|
|
|
|
/**
|
|
* 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<Notice | null> {
|
|
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: readonly string[] = UNITS.map((u) => u.id);
|
|
export const DEFAULT_UOM_MAP: Record<string, string> = Object.fromEntries(UNITS.map((u) => [u.id, u.uom]));
|
|
|
|
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
|
|
const out: Record<string, string> = { ...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<string, string> = {
|
|
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<string, string> {
|
|
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: [],
|
|
});
|