diff --git a/docs/voiced-invoice-v1.md b/docs/voiced-invoice-v1.md new file mode 100644 index 0000000..80c7e09 --- /dev/null +++ b/docs/voiced-invoice-v1.md @@ -0,0 +1,51 @@ +# voiced.invoice.v1 + +A neutral export of issued invoices for software Voiced has no direct integration with. Choose invoices in the +History list (checkboxes) or open one, then use "Export selected (voiced.invoice.v1 JSON)" or "(line items CSV)". +The code is `src/lib/invoiceExportV1.ts`; `src/lib/__golden__/invoice-v1.golden.json` is a full example. + +## Rules + +- **Money is exact.** Every amount appears twice: `_paise` (an integer) and `` (a decimal string with two + places, e.g. `"7310.10"`). Nothing is a float. Rates (percent) are decimal strings such as `"9"` or `"6.25"`. +- **Deterministic.** The same invoices give the same bytes: fixed key order, no timestamps, input order kept. +- **JSON file** is an array with one object per invoice; each object carries `"schema": "voiced.invoice.v1"`. +- Cancelled invoices are included with `"cancelled": true` (their figures are kept; filter them out of sums). +- An unregistered supplier's plain "Invoice" has `document_type: "invoice"`, all tax heads zero and no supplier GSTIN. + +## Invoice object + +| Key | Meaning | +| --- | --- | +| `schema`, `number`, `issue_date`, `due_date` | `YYYY-MM-DD` dates; `due_date` is null when unset | +| `document_type` | `tax_invoice` or `invoice` | +| `status`, `cancelled`, `cancelled_at`, `cancel_reason` | `status` is `issued` or `cancelled` | +| `currency`, `po_number` | always `INR` | +| `supplier` | `name`, `address`, `gstin` (null if none), `pan`, `gst_registration`, `state {code, name}`; frozen at issue | +| `client` | `name`, `gstin`, `gst_category`, `address {text, line1, line2, city, pincode, state}`, `place_of_supply {code, name}` | +| `items[]` | `line`, `description`, `hsn_sac`, `quantity` (decimal string), `unit`, `rate`, `taxable_value` (each with `_paise`) | +| `tax` | `cgst`, `sgst`, `utgst`, `igst`, each `{rate, amount_paise, amount}`; heads that do not apply are zero | +| `totals` | `subtotal`, `discount`, `taxable_value`, `tax`, `total` (each with `_paise`), `amount_in_words` | +| `reverse_charge`, `notes` | | +| `payments[]` | `date`, `amount` (cash), `tds`, `mode`, `reference` (amounts with `_paise`) | + +Notes on the fields: + +- **Items.** `taxable_value` of a line is the line amount before the invoice-level discount. `totals.taxable_value` is + `subtotal - discount`, the base the tax is charged on. A fixed-amount line has `quantity "1"` and `unit null`. +- **UTGST.** When the supplier is in a union territory without a legislature (state codes 04, 26, 31, 35, 38) the second + head is reported under `utgst` and `sgst` is zero. +- **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured + address parts and `gst_category` come from the client's saved record at export time (null or derived from the GSTIN + when the invoice has no saved client). +- **Supplier snapshot.** Only name, address, GSTIN, PAN, registration and state are exported; logo paths, e-mail and + phone are not. + +## Line-items CSV + +One row per line item, UTF-8 with a byte order mark and CRLF line ends; text cells that a spreadsheet would read as a +formula get a leading apostrophe. Columns: `invoice_number, issue_date, due_date, document_type, status, cancelled, +currency, supplier_name, supplier_gstin, client_name, client_gstin, client_gst_category, place_of_supply_code, +place_of_supply, reverse_charge, line, description, hsn_sac, quantity, unit, rate, taxable_value, invoice_discount, +invoice_cgst, invoice_sgst, invoice_utgst, invoice_igst, invoice_total`. The `invoice_*` amounts are filled on the first +row of each invoice only, so summing a column never counts an invoice twice. diff --git a/src-tauri/src/commands/payments.rs b/src-tauri/src/commands/payments.rs index 8f25404..c4ef747 100644 --- a/src-tauri/src/commands/payments.rs +++ b/src-tauri/src/commands/payments.rs @@ -30,6 +30,9 @@ pub struct Payment { pub reference: String, pub notes: String, pub created_at: String, + /// Name of the Payment Entry on ERPNext once this payment was sent; `None` until then. + #[serde(default)] + pub erpnext_payment_entry: Option, } #[derive(Debug, Clone, Deserialize)] @@ -127,10 +130,11 @@ fn map_payment(r: &rusqlite::Row) -> rusqlite::Result { reference: r.get(6)?, notes: r.get(7)?, created_at: r.get(8)?, + erpnext_payment_entry: r.get::<_, Option>(9)?.filter(|e| !e.trim().is_empty()), }) } -const COLS: &str = "id, invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at"; +const COLS: &str = "id, invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at, erpnext_payment_entry"; pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result { let db = |e: rusqlite::Error| e.to_string(); diff --git a/src-tauri/src/integrations/erpnext/push.rs b/src-tauri/src/integrations/erpnext/push.rs index 8d5287b..63ebb4a 100644 --- a/src-tauri/src/integrations/erpnext/push.rs +++ b/src-tauri/src/integrations/erpnext/push.rs @@ -323,6 +323,14 @@ fn load_item_codes(conn: &Connection, inv: &Invoice) -> Result bool { + match serde_json::from_str::(raw) { + Ok(Value::Object(m)) => !m.is_empty(), + _ => false, + } +} + fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result { let conn = db .lock() @@ -346,6 +354,11 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result { + requestSettingsTab("integrations"); + setView("settings"); + }; + const toggleTheme = async () => { const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" }; setSettings(next); @@ -115,6 +121,7 @@ function AppInner() { { setDetailId(id); setView("invoice"); @@ -127,6 +134,7 @@ function AppInner() { invoiceId={detailId} settings={settings} onBack={() => setView("invoices")} + onOpenSettings={openIntegrations} onDuplicated={(draftId) => { setOpenDraftId(draftId); setView("new"); diff --git a/src/components/ErpnextSettingsPanel.tsx b/src/components/ErpnextSettingsPanel.tsx new file mode 100644 index 0000000..5038a55 --- /dev/null +++ b/src/components/ErpnextSettingsPanel.tsx @@ -0,0 +1,591 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Button, + ComboBox, + FileUploaderButton, + InlineLoading, + InlineNotification, + PasswordInput, + RadioButton, + RadioButtonGroup, + TextArea, + TextInput, + Tag, + Tile, + Toggle, +} from "@carbon/react"; +import { Save } from "@carbon/icons-react"; +import { api } from "../lib/api"; +import type { ErpnextConfig, ErpnextConfigInput, ErpnextConnectionTest, ErpnextOptionItem, ErpnextOptions } from "../lib/erpnext"; +import { + OPTION_LIST_LABELS, + UOM_UNITS, + connectionProblems, + emptyOptions, + hasProblems, + mirrorSupported, + optionErrors, + optionText, + sortWarnings, + toInput, + validateConfigForm, + versionsLine, + warningSeverity, + withStoredValue, + type FormProblems, +} from "../lib/erpnextUi"; +import { useToast } from "./ToastProvider"; + +const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const; + +interface PickProps { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + /** The loaded list; an empty list means "not loaded", and the field takes typed text instead. */ + items: ErpnextOptionItem[]; + /** Why this list is empty although the lists were loaded (a load error for this list). */ + error?: string; + /** The control is switched off, with the reason shown under it. */ + disabledReason?: string; + helper?: string; +} + +/** A searchable dropdown over a live list, or a text box while the list has not been loaded. */ +function PickField({ id, label, value, onChange, items, error, disabledReason, helper }: PickProps) { + const options = useMemo(() => withStoredValue(items, value), [items, value]); + const note = disabledReason ?? error ?? helper; + if (items.length === 0 || disabledReason) { + return ( + onChange(e.target.value)} + /> + ); + } + return ( + (i ? optionText(i) : "")} + selectedItem={options.find((o) => o.name === value) ?? null} + onChange={({ selectedItem }: { selectedItem?: ErpnextOptionItem | null }) => onChange(selectedItem?.name ?? "")} + shouldFilterItem={({ item, inputValue }: { item: ErpnextOptionItem; inputValue: string | null }) => + !inputValue || optionText(item).toLowerCase().includes(inputValue.toLowerCase()) + } + helperText={helper} + placeholder="Search or choose" + /> + ); +} + +function Section({ title, children, note }: { title: string; children: React.ReactNode; note?: string }) { + return ( +
+

{title}

+ {note ?

{note}

: null} +
{children}
+
+ ); +} + +/** Settings, Integrations, ERPNext. It has its own Save: the page-level Save settings button does not cover it. */ +export default function ErpnextSettingsPanel({ active }: { active: boolean }) { + const toast = useToast(); + const [saved, setSaved] = useState(null); + const [form, setForm] = useState(null); + const [loadError, setLoadError] = useState(null); + const [replacing, setReplacing] = useState(false); + const [test, setTest] = useState(null); + /** A test result from this session that is not saved yet. */ + const [testUnsaved, setTestUnsaved] = useState(false); + const [testError, setTestError] = useState(null); + const [testing, setTesting] = useState(false); + const [options, setOptions] = useState(null); + const [loadingOptions, setLoadingOptions] = useState(false); + const [saving, setSaving] = useState(false); + const [showProblems, setShowProblems] = useState(false); + const [saveError, setSaveError] = useState(null); + + const load = useCallback(async () => { + try { + const cfg = await api.erpnextGetConfig(); + setSaved(cfg); + setForm(toInput(cfg)); + setTest(cfg.lastDetectResult); + setTestUnsaved(false); + setLoadError(null); + } catch (e) { + setLoadError(String(e)); + } + }, []); + + useEffect(() => { + if (active && form === null) void load(); + // Reload only on first show; edits must survive switching tabs. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active]); + + if (loadError) { + return ; + } + if (!form || !saved) return ; + + const set = (key: K, value: ErpnextConfigInput[K]) => setForm((f) => (f ? { ...f, [key]: value } : f)); + const secretStored = saved.apiSecretSet; + const secretShown = secretStored && !replacing && !form.clearSecret; + const problems: FormProblems = showProblems ? validateConfigForm(form, secretStored, test) : {}; + const connProblems = connectionProblems(form, secretStored); + const canConnect = !hasProblems(connProblems); + const optErrors = optionErrors(options); + const opts = options ?? emptyOptions(); + const noCompany = !form.company.trim(); + const companyReason = noCompany ? "Pick a company first." : undefined; + const seriesMode = form.namingMode === "series"; + const mirrorOk = mirrorSupported(test); + + const loadOptions = async (values: ErpnextConfigInput) => { + setLoadingOptions(true); + try { + setOptions(await api.erpnextLoadOptions(values)); + } catch (e) { + setOptions(null); + toast.error("Could not load the ERPNext lists", String(e)); + } finally { + setLoadingOptions(false); + } + }; + + const runTest = async () => { + setShowProblems(true); + if (!canConnect) return; + setTesting(true); + setTestError(null); + try { + const result = await api.erpnextTestConnection(form); + setTest(result); + setTestUnsaved(true); + let next = form; + // A site that keeps no posted name cannot mirror the Voiced number. + if (!result.features.v2Naming && form.namingMode === "mirror") { + next = { ...form, namingMode: "series" }; + setForm(next); + } + await loadOptions(next); + } catch (e) { + setTestError(String(e)); + } finally { + setTesting(false); + } + }; + + const changeCompany = (company: string) => { + const next = { ...form, company }; + setForm(next); + // Accounts, addresses and cost centers belong to the company, so the lists are reloaded for it. + if (options && canConnect) void loadOptions(next); + }; + + const save = async () => { + setShowProblems(true); + setSaveError(null); + if (hasProblems(validateConfigForm(form, secretStored, test))) return; + setSaving(true); + try { + const cfg = await api.erpnextSaveConfig({ ...form, ...(testUnsaved && test ? { lastDetectResult: test } : {}) }); + setSaved(cfg); + setForm(toInput(cfg)); + setReplacing(false); + setTest(cfg.lastDetectResult ?? test); + setTestUnsaved(false); + setShowProblems(false); + toast.success("ERPNext settings saved"); + } catch (e) { + const text = String(e); + setSaveError(text); + toast.error("Could not save the ERPNext settings", text); + } finally { + setSaving(false); + } + }; + + const readPem = async (file: File | undefined) => { + if (!file) return; + try { + set("extraCaPem", (await file.text()).trim()); + } catch (e) { + toast.error("Could not read the certificate file", String(e)); + } + }; + + const secretInvalid = problems.apiSecret ?? (saveError && /secret again/i.test(saveError) ? saveError : undefined); + + return ( +
+
+

ERPNext

+

+ Send issued invoices and payments to your ERPNext site as Sales Invoices. Everything is sent from this computer; ERPNext is never + needed to issue an invoice. +

+

+ Create a dedicated ERPNext user for Voiced with only these permissions: create and submit Sales Invoice, read on the master data + (Customer, Item, Address, Account, Company), and write on File. Use that user’s API key and secret below. An address must be + https://; plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test hosts. +

+
+ +
+ set("baseUrl", e.target.value)} + /> + set("apiKey", e.target.value)} + /> + {secretShown ? ( +
+

+ API secret +

+

+ + set + {" "} + The secret is stored on this computer and is never shown again. +

+
+ + +
+
+ ) : ( +
+ set("apiSecret", e.target.value)} + /> + {secretStored ? ( + + ) : null} +
+ )} +
+