Add the ERPNext settings UI, History and detail controls, and voiced.invoice.v1 export (Phase F3)
- Settings gets an Integrations tab: connection fields with a write-only API secret, a CA PEM option, Test connection with versions and warnings, live mapping lists loaded after a test, naming mode, toggles and its own Save. - Pushing for a GST-registered vendor is refused until a connection test result is stored. The UI shows the reason and disables Send. - History shows an ERPNext status column from one bulk call, a row Send and Open in ERPNext, and a bulk Send selected with a draft-or-submit confirm step and per-row results that never abort the batch. The detail view gets an ERPNext panel and per-payment send. All of it is hidden or disabled when the integration is not configured. - Optional auto-push after issue runs in the background and never blocks or fails the issue. - Generic export: a deterministic voiced.invoice.v1 JSON (and CSV line items) for selected invoices, documented in docs/voiced-invoice-v1.md with a golden test. Money is integer paise plus a decimal string. The new screens have not been run in a webview yet. The ERPNext Payment Entry deduction fields still need a live check.
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ErpnextConfig, ErpnextConnectionTest, ErpnextPushResult, ErpnextSyncStatus } from "./erpnext";
|
||||
import {
|
||||
autoPushIssued,
|
||||
baseUrlProblem,
|
||||
connectionProblems,
|
||||
describePaymentPush,
|
||||
describePush,
|
||||
failedPush,
|
||||
hasDetectResult,
|
||||
isConfigured,
|
||||
paymentPushDisabledReason,
|
||||
pushDisabledReason,
|
||||
summarizeBatch,
|
||||
syncTag,
|
||||
toInput,
|
||||
validateConfigForm,
|
||||
warningSeverity,
|
||||
withDefaultUoms,
|
||||
withStoredValue,
|
||||
sortWarnings,
|
||||
sendableSelection,
|
||||
formatSyncedAt,
|
||||
} from "./erpnextUi";
|
||||
|
||||
const test: ErpnextConnectionTest = {
|
||||
user: "api@example.com",
|
||||
versions: { frappe: "15.80.0", erpnext: "15.70.0", indiaCompliance: "15.30.0" },
|
||||
features: { v2Naming: true, indiaCompliance: true },
|
||||
indiaCompliance: true,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const config = (patch: Partial<ErpnextConfig> = {}): ErpnextConfig => ({
|
||||
baseUrl: "https://erp.example.com",
|
||||
apiKey: "key",
|
||||
apiSecretSet: true,
|
||||
extraCaPem: "",
|
||||
company: "Test Co",
|
||||
companyAddress: "",
|
||||
namingMode: "mirror",
|
||||
namingSeries: "",
|
||||
incomeAccount: "",
|
||||
costCenter: "",
|
||||
cgstAccount: "",
|
||||
sgstAccount: "",
|
||||
utgstAccount: "",
|
||||
igstAccount: "",
|
||||
taxTemplateIntra: "",
|
||||
taxTemplateInter: "",
|
||||
paymentBankAccount: "",
|
||||
tdsAccount: "",
|
||||
defaultItemCode: "",
|
||||
uomMap: {},
|
||||
customerGroup: "",
|
||||
territory: "",
|
||||
sellingPriceList: "",
|
||||
submitOnPush: false,
|
||||
attachPdf: true,
|
||||
autoPushOnIssue: false,
|
||||
createMissingCustomers: true,
|
||||
lastDetectResult: test,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const sync = (patch: Partial<ErpnextSyncStatus>): ErpnextSyncStatus => ({
|
||||
invoiceId: 1,
|
||||
status: "synced",
|
||||
remoteName: "INV/2026-001",
|
||||
remoteDocstatus: 0,
|
||||
lastError: "",
|
||||
syncedAt: null,
|
||||
attached: false,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const push = (patch: Partial<ErpnextPushResult> = {}): ErpnextPushResult => ({
|
||||
invoiceId: 1,
|
||||
number: "INV/2026-001",
|
||||
ok: true,
|
||||
status: "synced",
|
||||
remoteName: "INV/2026-001",
|
||||
remoteDocstatus: 0,
|
||||
created: true,
|
||||
noOp: false,
|
||||
attached: true,
|
||||
error: null,
|
||||
errorKind: null,
|
||||
warnings: [],
|
||||
...patch,
|
||||
});
|
||||
|
||||
describe("status tags", () => {
|
||||
it("maps every sync state", () => {
|
||||
expect(syncTag(undefined)).toMatchObject({ label: "Not sent", type: "gray" });
|
||||
expect(syncTag(sync({ status: "none" })).label).toBe("Not sent");
|
||||
expect(syncTag(sync({ remoteDocstatus: 0 }))).toMatchObject({ label: "Draft in ERPNext", type: "blue" });
|
||||
expect(syncTag(sync({ remoteDocstatus: 1 }))).toMatchObject({ label: "Submitted", type: "green" });
|
||||
expect(syncTag(sync({ status: "error", lastError: "Item missing" }))).toMatchObject({ label: "Error", type: "red", tooltip: "Item missing" });
|
||||
expect(syncTag(sync({ status: "conflict", lastError: "" }))).toMatchObject({ label: "Conflict", type: "purple" });
|
||||
expect(syncTag(sync({ status: "error", lastError: "" })).tooltip).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("configured and disabled reasons", () => {
|
||||
it("needs an address, a key and a stored secret", () => {
|
||||
expect(isConfigured(config())).toBe(true);
|
||||
expect(isConfigured(config({ baseUrl: " " }))).toBe(false);
|
||||
expect(isConfigured(config({ apiKey: "" }))).toBe(false);
|
||||
expect(isConfigured(config({ apiSecretSet: false }))).toBe(false);
|
||||
expect(isConfigured(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for a connection test only when the vendor is registered", () => {
|
||||
const untested = config({ lastDetectResult: null });
|
||||
expect(pushDisabledReason({ cfg: untested, vendorRegistered: true })).toMatch(/Run Test connection first/);
|
||||
expect(pushDisabledReason({ cfg: untested, vendorRegistered: false })).toBeNull();
|
||||
expect(pushDisabledReason({ cfg: config(), vendorRegistered: true })).toBeNull();
|
||||
expect(hasDetectResult(null)).toBe(false);
|
||||
expect(hasDetectResult({} as ErpnextConnectionTest)).toBe(false);
|
||||
expect(pushDisabledReason({ cfg: null, vendorRegistered: false })).toMatch(/not set up/);
|
||||
expect(pushDisabledReason({ cfg: config(), vendorRegistered: false, invoiceStatus: "cancelled" })).toMatch(/Cancelled/);
|
||||
expect(pushDisabledReason({ cfg: config(), vendorRegistered: false, invoiceStatus: "issued" })).toBeNull();
|
||||
});
|
||||
|
||||
it("allows a payment only against a submitted invoice, once", () => {
|
||||
const cfg = config();
|
||||
expect(paymentPushDisabledReason({ cfg, sync: undefined, entry: null })).toMatch(/Send the invoice/);
|
||||
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 0 }), entry: null })).toMatch(/Submit the invoice in ERPNext first/);
|
||||
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 1 }), entry: null })).toBeNull();
|
||||
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 1 }), entry: "ACC-PAY-1" })).toMatch(/ACC-PAY-1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("push results", () => {
|
||||
it("describes outcomes", () => {
|
||||
expect(describePush(push())).toMatchObject({ kind: "success", subtitle: "INV/2026-001 (draft)" });
|
||||
expect(describePush(push({ remoteDocstatus: 1 })).subtitle).toContain("submitted");
|
||||
expect(describePush(push({ noOp: true })).kind).toBe("info");
|
||||
const warned = describePush(push({ warnings: ["The PDF was not attached: no permission"] }));
|
||||
expect(warned.kind).toBe("warning");
|
||||
expect(warned.subtitle).toContain("no permission");
|
||||
expect(describePush(push({ ok: false, status: "refused", error: "Run Test connection first", errorKind: "precondition" })).kind).toBe("warning");
|
||||
expect(describePush(push({ ok: false, status: "conflict", error: "different total" })).title).toMatch(/conflicts/);
|
||||
expect(describePush(push({ ok: false, status: "error", error: "417", errorKind: "validation" })).kind).toBe("error");
|
||||
expect(describePaymentPush({ paymentId: 1, invoiceId: 1, ok: true, entryName: "PE-1", alreadySynced: false, error: null, errorKind: null }).kind).toBe("success");
|
||||
expect(describePaymentPush({ paymentId: 1, invoiceId: 1, ok: false, entryName: null, alreadySynced: false, error: "nope", errorKind: "other" }).kind).toBe("error");
|
||||
});
|
||||
|
||||
it("turns a rejected call into a failed row", () => {
|
||||
const r = failedPush(3, "INV/3", "ERPNext is not set up yet");
|
||||
expect(r).toMatchObject({ ok: false, invoiceId: 3, number: "INV/3", error: "ERPNext is not set up yet" });
|
||||
expect(failedPush(3, "INV/3", new Error("boom")).error).toBe("boom");
|
||||
});
|
||||
|
||||
it("summarises a batch", () => {
|
||||
const s = summarizeBatch([push(), push({ noOp: true }), push({ ok: false, status: "error", error: "x" })]);
|
||||
expect(s).toMatchObject({ total: 3, sent: 1, upToDate: 1, failed: 1, kind: "warning" });
|
||||
expect(s.title).toBe("1 sent, 1 already up to date, 1 failed");
|
||||
expect(summarizeBatch([push(), push()]).kind).toBe("success");
|
||||
expect(summarizeBatch([push({ ok: false })]).kind).toBe("error");
|
||||
expect(summarizeBatch([push({ warnings: ["w"] })]).kind).toBe("warning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto push on issue", () => {
|
||||
const invoice = { id: 7, number: "INV/2026-007" };
|
||||
|
||||
it("does nothing when it is off or ERPNext is not set up", async () => {
|
||||
const pushFn = vi.fn();
|
||||
expect(await autoPushIssued({ getConfig: async () => config(), push: pushFn }, invoice, true)).toBeNull();
|
||||
expect(await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true, apiSecretSet: false }), push: pushFn }, invoice, true)).toBeNull();
|
||||
expect(await autoPushIssued({ getConfig: async () => Promise.reject(new Error("db")), push: pushFn }, invoice, true)).toBeNull();
|
||||
expect(pushFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pushes with the configured submit setting and reports the result", async () => {
|
||||
const pushFn = vi.fn(async () => push({ number: invoice.number }));
|
||||
const notice = await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true }), push: pushFn }, invoice, true);
|
||||
expect(pushFn).toHaveBeenCalledWith(7);
|
||||
expect(notice?.kind).toBe("success");
|
||||
});
|
||||
|
||||
it("never throws: a rejected push becomes an error notice", async () => {
|
||||
const notice = await autoPushIssued(
|
||||
{ getConfig: async () => config({ autoPushOnIssue: true }), push: async () => Promise.reject("ERPNext is not set up yet") },
|
||||
invoice,
|
||||
false,
|
||||
);
|
||||
expect(notice).toMatchObject({ kind: "error" });
|
||||
expect(notice?.subtitle).toContain("not set up");
|
||||
});
|
||||
|
||||
it("explains a missing connection test instead of pushing", async () => {
|
||||
const pushFn = vi.fn();
|
||||
const notice = await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true, lastDetectResult: null }), push: pushFn }, invoice, true);
|
||||
expect(pushFn).not.toHaveBeenCalled();
|
||||
expect(notice?.subtitle).toMatch(/Run Test connection first/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnings", () => {
|
||||
it("ranks errors before warnings before notes", () => {
|
||||
expect(warningSeverity("gst_accounts_not_configured")).toBe("error");
|
||||
expect(warningSeverity("ic_not_detected")).toBe("warning");
|
||||
expect(warningSeverity("versions_unknown")).toBe("info");
|
||||
const sorted = sortWarnings([
|
||||
{ code: "versions_unknown", message: "a" },
|
||||
{ code: "company_missing", message: "b" },
|
||||
{ code: "ic_not_detected", message: "c" },
|
||||
]);
|
||||
expect(sorted.map((w) => w.code)).toEqual(["company_missing", "ic_not_detected", "versions_unknown"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings form", () => {
|
||||
it("validates the address like the backend", () => {
|
||||
expect(baseUrlProblem("")).toMatch(/Enter/);
|
||||
expect(baseUrlProblem("https://erp.example.com")).toBeNull();
|
||||
expect(baseUrlProblem("erp.example.com")).toBeNull();
|
||||
expect(baseUrlProblem("http://erp.example.com")).toMatch(/https/);
|
||||
expect(baseUrlProblem("http://localhost:8000")).toBeNull();
|
||||
expect(baseUrlProblem("localhost:8000")).toBeNull();
|
||||
expect(baseUrlProblem("http://site.test")).toBeNull();
|
||||
expect(baseUrlProblem("http://127.0.0.1:8000")).toBeNull();
|
||||
expect(baseUrlProblem("https://user:pw@erp.example.com")).toMatch(/credentials/);
|
||||
expect(baseUrlProblem("ftp://erp.example.com")).toMatch(/https/);
|
||||
});
|
||||
|
||||
it("builds the input without write-only fields and fills the UOM defaults", () => {
|
||||
const input = toInput(config({ uomMap: { hour: "Hr" } }));
|
||||
expect(input).not.toHaveProperty("apiSecretSet");
|
||||
expect(input).not.toHaveProperty("lastDetectResult");
|
||||
expect(input.apiSecret).toBe("");
|
||||
expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" });
|
||||
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
|
||||
});
|
||||
|
||||
it("needs a secret only when none is stored or it is being cleared", () => {
|
||||
const form = toInput(config());
|
||||
expect(connectionProblems(form, true)).toEqual({});
|
||||
expect(connectionProblems(form, false).apiSecret).toBeTruthy();
|
||||
expect(connectionProblems({ ...form, clearSecret: true }, true).apiSecret).toBeTruthy();
|
||||
expect(connectionProblems({ ...form, clearSecret: true, apiSecret: "new" }, true)).toEqual({});
|
||||
expect(connectionProblems({ ...form, apiKey: " " }, true).apiKey).toBeTruthy();
|
||||
});
|
||||
|
||||
it("validates a save: untouched is fine, series mode needs a series, mirror needs v2 naming", () => {
|
||||
const empty = toInput(config({ baseUrl: "", apiKey: "", apiSecretSet: false }));
|
||||
expect(validateConfigForm(empty, false, null)).toEqual({});
|
||||
const form = toInput(config());
|
||||
expect(validateConfigForm(form, true, test)).toEqual({});
|
||||
expect(validateConfigForm({ ...form, namingMode: "series", namingSeries: "" }, true, test).namingSeries).toBeTruthy();
|
||||
expect(validateConfigForm({ ...form, namingMode: "series", namingSeries: "SINV-.YY.-" }, true, test)).toEqual({});
|
||||
const old = { ...test, features: { v2Naming: false, indiaCompliance: true } };
|
||||
expect(validateConfigForm(form, true, old).namingMode).toBeTruthy();
|
||||
expect(validateConfigForm(form, true, null).namingMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a saved value visible when the loaded list lacks it", () => {
|
||||
const items = [{ name: "A", label: "A", detail: "" }];
|
||||
expect(withStoredValue(items, "A")).toBe(items);
|
||||
expect(withStoredValue(items, "")).toBe(items);
|
||||
const out = withStoredValue(items, "Sales - AC");
|
||||
expect(out[0]).toMatchObject({ name: "Sales - AC" });
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection and dates", () => {
|
||||
it("leaves cancelled invoices out of a send", () => {
|
||||
const rows = [
|
||||
{ id: 1, status: "issued" },
|
||||
{ id: 2, status: "cancelled" },
|
||||
{ id: 3, status: "issued" },
|
||||
];
|
||||
expect(sendableSelection(rows)).toEqual({ send: [rows[0], rows[2]], skipped: 1 });
|
||||
expect(sendableSelection([])).toEqual({ send: [], skipped: 0 });
|
||||
});
|
||||
|
||||
it("formats the sync time defensively", () => {
|
||||
expect(formatSyncedAt(null)).toBe("");
|
||||
expect(formatSyncedAt("not a date")).toBe("not a date");
|
||||
expect(formatSyncedAt("2026-04-01T10:30:00+00:00")).toMatch(/2026/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user