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:
2026-10-04 20:57:31 +05:30
parent 15f942165f
commit 3591bb9f7d
24 changed files with 2855 additions and 11 deletions
+51
View File
@@ -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: `<name>_paise` (an integer) and `<name>` (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.
+5 -1
View File
@@ -30,6 +30,9 @@ pub struct Payment {
pub reference: String, pub reference: String,
pub notes: String, pub notes: String,
pub created_at: 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<String>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -127,10 +130,11 @@ fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
reference: r.get(6)?, reference: r.get(6)?,
notes: r.get(7)?, notes: r.get(7)?,
created_at: r.get(8)?, created_at: r.get(8)?,
erpnext_payment_entry: r.get::<_, Option<String>>(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<Payment, String> { pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result<Payment, String> {
let db = |e: rusqlite::Error| e.to_string(); let db = |e: rusqlite::Error| e.to_string();
@@ -323,6 +323,14 @@ fn load_item_codes(conn: &Connection, inv: &Invoice) -> Result<Vec<Option<String
Ok(inv.items.iter().map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned()).collect()) Ok(inv.items.iter().map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned()).collect())
} }
/// True when a connection test result is stored (blank, `null` and `{}` count as none).
fn has_detect_result(raw: &str) -> bool {
match serde_json::from_str::<Value>(raw) {
Ok(Value::Object(m)) => !m.is_empty(),
_ => false,
}
}
fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (String, ErpError)> { fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (String, ErpError)> {
let conn = db let conn = db
.lock() .lock()
@@ -346,6 +354,11 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?; let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?;
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot) let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot)
.ok_or_else(|| fail(pre(format!("Invoice {number} has no supplier details recorded, so it cannot be sent."))))?; .ok_or_else(|| fail(pre(format!("Invoice {number} has no supplier details recorded, so it cannot be sent."))))?;
// A registered vendor's invoices go out with India Compliance fields, and those depend on what the
// connection test detected, so refuse until the test has run once.
if vendor.registered && !has_detect_result(&cfg.last_detect_result) {
return Err(fail(pre("Run Test connection first (Settings, Integrations): Voiced has not yet checked this ERPNext site.")));
}
let client = load_client(&conn, &invoice).map_err(|e| fail(pre(e)))?; let client = load_client(&conn, &invoice).map_err(|e| fail(pre(e)))?;
let item_codes = load_item_codes(&conn, &invoice).map_err(|e| fail(pre(e)))?; let item_codes = load_item_codes(&conn, &invoice).map_err(|e| fail(pre(e)))?;
let sync = load_sync(&conn, invoice_id).map_err(|e| fail(pre(e)))?; let sync = load_sync(&conn, invoice_id).map_err(|e| fail(pre(e)))?;
@@ -518,6 +518,45 @@ async fn cancelled_and_draft_invoices_are_refused_before_any_request() {
assert!(fx.sync_row(inv.id).is_none() && fx.sync_row(draft_id).is_none()); assert!(fx.sync_row(inv.id).is_none() && fx.sync_row(draft_id).is_none());
} }
#[tokio::test]
async fn a_registered_vendor_must_run_the_connection_test_before_pushing() {
let fx = Fx::new(|c| c.last_detect_result = String::new());
let inv = fx.issue("Client Ltd");
let mut routes = master_routes();
routes.push(mirror_create(&inv.number, 0, inv.total));
let m = mock(routes).await;
let r = fx.push(&m, inv.id, Some(false)).await;
assert!(!r.ok && r.status == "refused" && r.error_kind == Some(ErrorKind::Precondition), "{r:?}");
assert!(r.error.as_deref().unwrap().contains("Run Test connection first"), "{r:?}");
assert!(m.requests.lock().unwrap().is_empty());
assert!(fx.sync_row(inv.id).is_none());
// `{}` and `null` are as good as nothing; a stored result unlocks the push.
assert!(!has_detect_result("") && !has_detect_result("{}") && !has_detect_result("null"));
fx.db.lock().unwrap().execute("UPDATE erpnext_config SET last_detect_result = '{\"indiaCompliance\":false}'", []).unwrap();
let r = fx.push(&m, inv.id, Some(false)).await;
assert!(r.ok, "{r:?}");
}
#[tokio::test]
async fn an_unregistered_vendor_needs_no_connection_test() {
let fx = Fx::new(|c| c.last_detect_result = String::new());
let inv = fx.issue("Other Co");
// Only the frozen supplier snapshot decides whether the invoice counts as a registered vendor's.
fx.db
.lock()
.unwrap()
.execute("UPDATE invoices SET vendor_snapshot = replace(vendor_snapshot, '\"gstRegistration\":\"regular\"', '\"gstRegistration\":\"unregistered\"')", [])
.unwrap();
assert!(!Vendor::from_snapshot(&get_invoice_impl(&fx.db.lock().unwrap(), inv.id).unwrap().vendor_snapshot).unwrap().registered);
let mut routes = master_routes();
routes.push(mirror_create(&inv.number, 0, inv.total));
let m = mock(routes).await;
let r = fx.push(&m, inv.id, Some(false)).await;
assert!(r.status != "refused", "{r:?}");
}
#[tokio::test] #[tokio::test]
async fn an_unarchived_invoice_is_pushed_without_a_pdf_and_attached_by_a_later_push() { async fn an_unarchived_invoice_is_pushed_without_a_pdf_and_attached_by_a_later_push() {
let fx = Fx::new(|_| {}); let fx = Fx::new(|_| {});
+8
View File
@@ -10,6 +10,7 @@ import InvoiceDetail from "./views/InvoiceDetail";
import Clients from "./views/Clients"; import Clients from "./views/Clients";
import { ToastProvider, useToast } from "./components/ToastProvider"; import { ToastProvider, useToast } from "./components/ToastProvider";
import AppSettings from "./views/AppSettings"; import AppSettings from "./views/AppSettings";
import { requestSettingsTab } from "./lib/settingsTab";
export default function App() { export default function App() {
return ( return (
@@ -89,6 +90,11 @@ function AppInner() {
); );
} }
const openIntegrations = () => {
requestSettingsTab("integrations");
setView("settings");
};
const toggleTheme = async () => { const toggleTheme = async () => {
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" }; const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
setSettings(next); setSettings(next);
@@ -115,6 +121,7 @@ function AppInner() {
<InvoiceHistory <InvoiceHistory
settings={settings} settings={settings}
active={view === "invoices"} active={view === "invoices"}
onOpenSettings={openIntegrations}
onOpen={(id) => { onOpen={(id) => {
setDetailId(id); setDetailId(id);
setView("invoice"); setView("invoice");
@@ -127,6 +134,7 @@ function AppInner() {
invoiceId={detailId} invoiceId={detailId}
settings={settings} settings={settings}
onBack={() => setView("invoices")} onBack={() => setView("invoices")}
onOpenSettings={openIntegrations}
onDuplicated={(draftId) => { onDuplicated={(draftId) => {
setOpenDraftId(draftId); setOpenDraftId(draftId);
setView("new"); setView("new");
+591
View File
@@ -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 (
<TextInput
id={id}
labelText={label}
value={value}
disabled={Boolean(disabledReason)}
invalid={Boolean(error) && !disabledReason}
invalidText={error}
helperText={error ? undefined : note}
onChange={(e) => onChange(e.target.value)}
/>
);
}
return (
<ComboBox
id={id}
titleText={label}
items={options}
itemToString={(i: ErpnextOptionItem | null) => (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 (
<section className="voiced-erp__section" aria-label={title}>
<h3>{title}</h3>
{note ? <p className="voiced-muted">{note}</p> : null}
<div className="voiced-erp__fields">{children}</div>
</section>
);
}
/** 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<ErpnextConfig | null>(null);
const [form, setForm] = useState<ErpnextConfigInput | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [replacing, setReplacing] = useState(false);
const [test, setTest] = useState<ErpnextConnectionTest | null>(null);
/** A test result from this session that is not saved yet. */
const [testUnsaved, setTestUnsaved] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [testing, setTesting] = useState(false);
const [options, setOptions] = useState<ErpnextOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(false);
const [saving, setSaving] = useState(false);
const [showProblems, setShowProblems] = useState(false);
const [saveError, setSaveError] = useState<string | null>(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 <InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the ERPNext settings" subtitle={loadError} />;
}
if (!form || !saved) return <InlineLoading description="Loading ERPNext settings" />;
const set = <K extends keyof ErpnextConfigInput>(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 (
<div className="voiced-stack voiced-erp">
<div>
<h2 className="voiced-detail__h2">ERPNext</h2>
<p>
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.
</p>
<p className="voiced-muted">
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&rsquo;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.
</p>
</div>
<Section title="Connection">
<TextInput
id="erp-baseUrl"
labelText="Site address"
placeholder="https://erp.example.com"
value={form.baseUrl}
invalid={Boolean(problems.baseUrl)}
invalidText={problems.baseUrl}
onChange={(e) => set("baseUrl", e.target.value)}
/>
<TextInput
id="erp-apiKey"
labelText="API key"
autoComplete="off"
value={form.apiKey}
invalid={Boolean(problems.apiKey)}
invalidText={problems.apiKey}
onChange={(e) => set("apiKey", e.target.value)}
/>
{secretShown ? (
<div className="voiced-erp__secret">
<p className="voiced-erp__label" id="erp-secret-label">
API secret
</p>
<p aria-labelledby="erp-secret-label">
<Tag type="green" size="sm">
set
</Tag>{" "}
<span className="voiced-muted">The secret is stored on this computer and is never shown again.</span>
</p>
<div className="voiced-header-action">
<Button kind="tertiary" size="sm" onClick={() => setReplacing(true)}>
Replace
</Button>
<Button kind="ghost" size="sm" onClick={() => set("clearSecret", true)}>
Clear
</Button>
</div>
</div>
) : (
<div>
<PasswordInput
id="erp-apiSecret"
labelText="API secret"
autoComplete="new-password"
value={form.apiSecret}
invalid={Boolean(secretInvalid)}
invalidText={secretInvalid}
helperText={form.clearSecret ? "The stored secret is removed when you save, unless you type a new one." : undefined}
onChange={(e) => set("apiSecret", e.target.value)}
/>
{secretStored ? (
<Button
kind="ghost"
size="sm"
onClick={() => {
setReplacing(false);
setForm((f) => (f ? { ...f, apiSecret: "", clearSecret: false } : f));
}}
>
Keep the stored secret
</Button>
) : null}
</div>
)}
<div>
<TextArea
id="erp-extraCa"
labelText="Extra CA certificate (optional, PEM)"
helperText="Only for a self-hosted site whose certificate is not trusted by this computer."
rows={3}
value={form.extraCaPem}
onChange={(e) => set("extraCaPem", e.target.value)}
/>
<FileUploaderButton
labelText="Load from file…"
buttonKind="ghost"
size="sm"
accept={[".pem", ".crt", ".cer"]}
disableLabelChanges
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
void readPem(file);
}}
/>
</div>
<div className="voiced-header-action">
<Button kind="secondary" size="md" onClick={() => void runTest()} disabled={testing || loadingOptions}>
{testing ? "Testing…" : "Test connection"}
</Button>
{testing || loadingOptions ? <InlineLoading description={testing ? "Contacting ERPNext" : "Loading lists"} /> : null}
{!canConnect && showProblems ? <span className="voiced-muted">Fix the address, key and secret first.</span> : null}
</div>
{testError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Connection failed" subtitle={testError} /> : null}
{test ? (
<Tile className="voiced-erp__result" aria-label="Connection test result">
<p>
<strong>Connected as {test.user || "an unknown user"}</strong>{" "}
<Tag type={test.indiaCompliance ? "green" : "warm-gray"} size="sm">
{test.indiaCompliance ? "India Compliance detected" : "India Compliance not detected"}
</Tag>
</p>
<p className="voiced-muted">{versionsLine(test)}</p>
{testUnsaved ? (
<InlineNotification
kind="info"
lowContrast
hideCloseButton
title="Save to keep this result"
subtitle="Sending invoices needs a saved connection test."
/>
) : (
<p className="voiced-muted">Last saved test result.</p>
)}
{sortWarnings(test.warnings).map((w, i) => {
const severity = warningSeverity(w.code);
return (
<InlineNotification
key={`${w.code}-${i}`}
kind={severity}
lowContrast
hideCloseButton
title={SEVERITY_TITLE[severity]}
subtitle={w.message}
/>
);
})}
{test.warnings.length === 0 ? <p className="voiced-muted">No problems found with the current settings.</p> : null}
<p className="voiced-muted">Test again after changing the mappings below to refresh these warnings.</p>
</Tile>
) : null}
</Section>
<Section
title="Company and accounts"
note={options ? "Choose from what your ERPNext site has." : "Run Test connection to choose from your site's lists; until then, type the exact names."}
>
{options && options.errors.length > 0 ? (
<InlineNotification
kind="warning"
lowContrast
hideCloseButton
title="Some lists could not be loaded"
subtitle={options.errors.map((e) => `${OPTION_LIST_LABELS[e.list] ?? e.list}: ${e.message}`).join(" ")}
/>
) : null}
<PickField
id="erp-company"
label="Company"
value={form.company}
onChange={changeCompany}
items={opts.companies}
error={optErrors.get("companies")}
/>
<PickField
id="erp-companyAddress"
label="Company address"
value={form.companyAddress}
onChange={(v) => set("companyAddress", v)}
items={opts.companyAddresses}
error={optErrors.get("companyAddresses")}
disabledReason={companyReason}
helper="Required by India Compliance on every invoice."
/>
<PickField
id="erp-incomeAccount"
label="Income account"
value={form.incomeAccount}
onChange={(v) => set("incomeAccount", v)}
items={opts.incomeAccounts}
error={optErrors.get("incomeAccounts")}
disabledReason={companyReason}
/>
<PickField
id="erp-costCenter"
label="Cost center"
value={form.costCenter}
onChange={(v) => set("costCenter", v)}
items={opts.costCenters}
error={optErrors.get("costCenters")}
disabledReason={companyReason}
/>
{(
[
["cgstAccount", "CGST account"],
["sgstAccount", "SGST account"],
["utgstAccount", "UTGST account"],
["igstAccount", "IGST account"],
] as const
).map(([key, label]) => (
<PickField
key={key}
id={`erp-${key}`}
label={label}
value={form[key]}
onChange={(v) => set(key, v)}
items={opts.taxAccounts}
error={optErrors.get("taxAccounts")}
disabledReason={companyReason}
helper={key === "utgstAccount" ? "Only needed when the supplier is in a union territory without a legislature." : "Must be the account set in GST Settings."}
/>
))}
<TextInput
id="erp-paymentBank"
labelText="Payment bank account"
helperText="The bank or cash account that receives payments, e.g. HDFC Bank - AC. Needed to send payments."
value={form.paymentBankAccount}
onChange={(e) => set("paymentBankAccount", e.target.value)}
/>
<TextInput
id="erp-tdsAccount"
labelText="TDS account"
helperText="The account that holds tax deducted by clients. Needed only for payments with TDS."
value={form.tdsAccount}
onChange={(e) => set("tdsAccount", e.target.value)}
/>
</Section>
<Section title="Customers and items">
<TextInput
id="erp-defaultItem"
labelText="Default item code (optional)"
helperText="Used for invoice lines without an item code of their own; a line with none is sent as a description-only row."
value={form.defaultItemCode}
onChange={(e) => set("defaultItemCode", e.target.value)}
/>
<PickField
id="erp-customerGroup"
label="Customer group for new customers"
value={form.customerGroup}
onChange={(v) => set("customerGroup", v)}
items={opts.customerGroups}
error={optErrors.get("customerGroups")}
/>
<PickField
id="erp-territory"
label="Territory for new customers"
value={form.territory}
onChange={(v) => set("territory", v)}
items={opts.territories}
error={optErrors.get("territories")}
/>
<PickField
id="erp-priceList"
label="Selling price list"
value={form.sellingPriceList}
onChange={(v) => set("sellingPriceList", v)}
items={opts.priceLists}
error={optErrors.get("priceLists")}
/>
<fieldset className="voiced-erp__uoms">
<legend>Units of measure</legend>
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.</p>
{UOM_UNITS.map((unit) => (
<PickField
key={unit}
id={`erp-uom-${unit}`}
label={`Voiced ${unit}`}
value={form.uomMap[unit] ?? ""}
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
items={opts.uoms}
error={optErrors.get("uoms")}
/>
))}
</fieldset>
</Section>
<Section title="Invoice numbers">
<RadioButtonGroup
legendText="Numbering in ERPNext"
name="erp-namingMode"
orientation="vertical"
valueSelected={form.namingMode}
onChange={(value) => set("namingMode", value === "series" ? "series" : "mirror")}
>
<RadioButton
id="erp-nm-mirror"
value="mirror"
labelText="Mirror the Voiced number (recommended)"
disabled={!mirrorOk}
/>
<RadioButton id="erp-nm-series" value="series" labelText="Use an ERPNext naming series" />
</RadioButtonGroup>
<p className="voiced-muted">
{mirrorOk
? "Mirror keeps the number printed on the PDF as the ERPNext invoice name, so GST returns match. Needs Frappe 15.73 or later."
: "Mirror is unavailable: this ERPNext version replaces a posted invoice number. The Voiced number is stored in the remarks instead."}
</p>
{problems.namingMode ? <p className="voiced-erp__error">{problems.namingMode}</p> : null}
<PickField
id="erp-namingSeries"
label="Naming series"
value={form.namingSeries}
onChange={(v) => set("namingSeries", v)}
items={opts.namingSeries}
error={problems.namingSeries ?? optErrors.get("namingSeries")}
disabledReason={seriesMode ? undefined : "Only used with an ERPNext naming series."}
/>
</Section>
<Section title="Sending">
<Toggle
id="erp-submit"
labelText="Submit invoices when sending"
labelA="Off: drafts for review"
labelB="On: submitted"
toggled={form.submitOnPush}
onToggle={(v) => set("submitOnPush", v)}
/>
<Toggle
id="erp-attach"
labelText="Attach the PDF"
labelA="Off"
labelB="On"
toggled={form.attachPdf}
onToggle={(v) => set("attachPdf", v)}
/>
<Toggle
id="erp-auto"
labelText="Send automatically when an invoice is issued"
labelA="Off"
labelB="On"
toggled={form.autoPushOnIssue}
onToggle={(v) => set("autoPushOnIssue", v)}
/>
<Toggle
id="erp-customers"
labelText="Create customers that are missing in ERPNext"
labelA="Off"
labelB="On"
toggled={form.createMissingCustomers}
onToggle={(v) => set("createMissingCustomers", v)}
/>
</Section>
{saveError && !secretInvalid ? <InlineNotification kind="error" lowContrast hideCloseButton title="Not saved" subtitle={saveError} /> : null}
<div>
<Button renderIcon={Save} onClick={() => void save()} disabled={saving}>
{saving ? "Saving…" : "Save ERPNext settings"}
</Button>
<p className="voiced-muted">This tab has its own Save button; Save settings at the bottom of the page does not cover it.</p>
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from "react";
import { Button, InlineLoading, Modal, RadioButton, RadioButtonGroup, Tag } from "@carbon/react";
import { api } from "../lib/api";
import type { ErpnextPushResult } from "../lib/erpnext";
import { describePush, failedPush, summarizeBatch } from "../lib/erpnextUi";
import { useReturnFocus } from "../hooks/useReturnFocus";
export interface SendTarget {
id: number;
number: string;
}
interface Props {
open: boolean;
invoices: SendTarget[];
/** Cancelled or draft invoices that were left out of the selection. */
skipped?: number;
/** The "submit on push" setting; the user still chooses explicitly. */
defaultSubmit: boolean;
onClose: () => void;
/** Called once when a send has finished, so the list can refresh its statuses. */
onFinished: (results: ErpnextPushResult[]) => void;
}
type Phase = "confirm" | "running" | "done";
const TAG = { success: "green", info: "blue", warning: "warm-gray", error: "red" } as const;
/** Confirm step (draft or submit), then a sequential send with progress, then per-invoice results. A failure never stops the rest. */
export default function SendToErpnextModal({ open, invoices, skipped = 0, defaultSubmit, onClose, onFinished }: Props) {
const [submit, setSubmit] = useState(defaultSubmit);
const [phase, setPhase] = useState<Phase>("confirm");
const [results, setResults] = useState<ErpnextPushResult[]>([]);
const alive = useRef(true);
useReturnFocus(open);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
useEffect(() => {
if (!open) return;
setSubmit(defaultSubmit);
setPhase("confirm");
setResults([]);
}, [open, defaultSubmit]);
const run = async () => {
setPhase("running");
const out: ErpnextPushResult[] = [];
for (const inv of invoices) {
let result: ErpnextPushResult;
try {
result = await api.erpnextPushInvoice(inv.id, submit);
} catch (e) {
// Not set up, or the command itself failed: this row fails, the others still go.
result = failedPush(inv.id, inv.number, e);
}
out.push(result);
if (alive.current) setResults([...out]);
}
if (alive.current) setPhase("done");
onFinished(out);
};
const count = invoices.length;
const noun = count === 1 ? "invoice" : "invoices";
if (phase === "confirm") {
return (
<Modal
open={open}
modalHeading={count === 1 ? `Send ${invoices[0]?.number} to ERPNext` : `Send ${count} invoices to ERPNext`}
primaryButtonText={`Send ${count} ${noun}`}
secondaryButtonText="Cancel"
primaryButtonDisabled={count === 0}
onRequestClose={onClose}
onRequestSubmit={() => void run()}
>
<div className="voiced-stack">
<RadioButtonGroup legendText="What should ERPNext do with it?" name="erp-send-mode" orientation="vertical" valueSelected={submit ? "submit" : "draft"} onChange={(v) => setSubmit(v === "submit")}>
<RadioButton id="erp-send-draft" value="draft" labelText="Save as a draft for review" />
<RadioButton id="erp-send-submit" value="submit" labelText="Submit it (posts to the books)" />
</RadioButtonGroup>
<p className="voiced-muted">
An invoice that is already in ERPNext is never overwritten or created twice.
{skipped > 0 ? ` ${skipped} cancelled ${skipped === 1 ? "invoice is" : "invoices are"} left out: cancelled invoices are never sent.` : ""}
</p>
</div>
</Modal>
);
}
const summary = phase === "done" ? summarizeBatch(results) : null;
return (
<Modal
open={open}
passiveModal
modalHeading={phase === "running" ? "Sending to ERPNext" : "Send finished"}
preventCloseOnClickOutside
onRequestClose={() => {
if (phase === "done") onClose();
}}
>
<div className="voiced-stack">
{phase === "running" ? (
<InlineLoading description={`Sent ${results.length} of ${count}…`} />
) : (
<p role="status">
<strong>{summary?.title}</strong>
</p>
)}
<ul className="voiced-erp__results" aria-label="Results">
{results.map((r) => {
const note = describePush(r);
return (
<li key={r.invoiceId}>
<Tag type={TAG[note.kind]} size="sm">
{r.ok ? (r.noOp ? "Up to date" : r.remoteDocstatus === 1 ? "Submitted" : "Draft") : r.status === "conflict" ? "Conflict" : r.status === "refused" ? "Not sent" : "Error"}
</Tag>{" "}
<strong>{r.number || `#${r.invoiceId}`}</strong>
{r.remoteName && r.ok ? <span className="voiced-muted"> {r.remoteName}</span> : null}
{!r.ok && r.error ? <div className="voiced-muted">{r.error}</div> : null}
{r.ok && r.warnings.length > 0 ? <div className="voiced-muted">{r.warnings.join(" ")}</div> : null}
</li>
);
})}
</ul>
{phase === "done" ? (
<Button kind="primary" size="md" onClick={onClose}>
Close
</Button>
) : null}
</div>
</Modal>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useState } from "react";
import { save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { exportInvoicesV1, type InvoiceExportFormat } from "../lib/invoiceExportV1";
import { useToast } from "../components/ToastProvider";
/** "Export as voiced.invoice.v1": JSON or line-item CSV of the given invoices, through the save dialog. */
export function useGenericExport(): { busy: boolean; run: (ids: number[], format: InvoiceExportFormat) => Promise<void> } {
const toast = useToast();
const [busy, setBusy] = useState(false);
const run = useCallback(
async (ids: number[], format: InvoiceExportFormat) => {
setBusy(true);
try {
const done = await exportInvoicesV1(
{
getInvoice: api.getInvoice,
listPayments: api.listPayments,
listClients: api.listClients,
getLastExportDir: api.getLastExportDir,
save: ({ defaultPath, format: fmt }) =>
save({ defaultPath, filters: [fmt === "csv" ? { name: "CSV (line items)", extensions: ["csv"] } : { name: "JSON (voiced.invoice.v1)", extensions: ["json"] }] }),
writeExportFile: api.writeExportFile,
now: () => new Date(),
},
ids,
format,
);
if (done) toast.success(`Exported ${done.count} ${done.count === 1 ? "invoice" : "invoices"}`, done.path);
} catch (e) {
toast.error("Could not export the invoices", String(e));
} finally {
setBusy(false);
}
},
[toast],
);
return { busy, run };
}
+118
View File
@@ -0,0 +1,118 @@
[
{
"schema": "voiced.invoice.v1",
"number": "INV/2026-001",
"issue_date": "2026-04-01",
"due_date": "2026-05-01",
"document_type": "tax_invoice",
"status": "issued",
"cancelled": false,
"cancelled_at": null,
"cancel_reason": null,
"currency": "INR",
"po_number": "PO-9",
"supplier": {
"name": "Test Vendor",
"address": "1 Main Rd\nPune",
"gstin": "27AAPFU0939F1ZV",
"pan": "AAPFU0939F",
"gst_registration": "regular",
"state": {
"code": "27",
"name": "Maharashtra"
}
},
"client": {
"name": "Client Ltd",
"gstin": "29AABCU9603R1ZJ",
"gst_category": "registered_regular",
"address": {
"text": "12 MG Road\nBengaluru 560001",
"line1": "12 MG Road",
"line2": null,
"city": "Bengaluru",
"pincode": "560001",
"state": {
"code": "29",
"name": "Karnataka"
}
},
"place_of_supply": {
"code": "29",
"name": "Karnataka"
}
},
"items": [
{
"line": 1,
"description": "Design",
"hsn_sac": "998391",
"quantity": "1",
"unit": null,
"rate_paise": 500010,
"rate": "5000.10",
"taxable_value_paise": 500010,
"taxable_value": "5000.10"
},
{
"line": 2,
"description": "Consulting",
"hsn_sac": null,
"quantity": "2",
"unit": "hour",
"rate_paise": 115505,
"rate": "1155.05",
"taxable_value_paise": 231000,
"taxable_value": "2310.00"
}
],
"tax": {
"cgst": {
"rate": "0",
"amount_paise": 0,
"amount": "0.00"
},
"sgst": {
"rate": "0",
"amount_paise": 0,
"amount": "0.00"
},
"utgst": {
"rate": "0",
"amount_paise": 0,
"amount": "0.00"
},
"igst": {
"rate": "18",
"amount_paise": 131400,
"amount": "1314.00"
}
},
"totals": {
"subtotal_paise": 731010,
"subtotal": "7310.10",
"discount_paise": 1010,
"discount": "10.10",
"taxable_value_paise": 730000,
"taxable_value": "7300.00",
"tax_paise": 131400,
"tax": "1314.00",
"total_paise": 861400,
"total": "8614.00",
"amount_in_words": "Rupees Eight Thousand Six Hundred Fourteen Only"
},
"reverse_charge": false,
"notes": "Thank you",
"payments": [
{
"date": "2026-04-10",
"amount_paise": 700000,
"amount": "7000.00",
"tds_paise": 70000,
"tds": "700.00",
"mode": "bank_transfer",
"reference": "UTR123"
}
]
}
]
+1 -1
View File
@@ -1,4 +1,4 @@
/** Types for the ERPNext commands (see src-tauri/src/commands/erpnext.rs). Types only: no UI yet. */ /** Types for the ERPNext commands (see src-tauri/src/commands/erpnext.rs); the UI helpers live in erpnextUi.ts. */
export type ErpnextNamingMode = "mirror" | "series"; export type ErpnextNamingMode = "mirror" | "series";
+7
View File
@@ -0,0 +1,7 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { api } from "./api";
/** Opens the invoice's ERPNext page in the system browser (`<base>/app/sales-invoice/<name>`); rejects if it was never sent. */
export async function openInErpnext(invoiceId: number): Promise<void> {
await openUrl(await api.erpnextOpenUrl(invoiceId));
}
+286
View File
@@ -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/);
});
});
+390
View File
@@ -0,0 +1,390 @@
/** 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<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 = ["second", "minute", "hour", "session", "unit"] as const;
export const DEFAULT_UOM_MAP: Record<string, string> = {
second: "Second",
minute: "Minute",
hour: "Hour",
session: "Nos",
unit: "Nos",
};
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: [],
});
+1 -1
View File
@@ -154,7 +154,7 @@ export interface HistoryExportDeps {
now(): Date; now(): Date;
} }
function joinPath(dir: string, name: string): string { export function joinPath(dir: string, name: string): string {
if (!dir) return name; if (!dir) return name;
if (/[\\/]$/.test(dir)) return `${dir}${name}`; if (/[\\/]$/.test(dir)) return `${dir}${name}`;
return `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`; return `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
+311
View File
@@ -0,0 +1,311 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
buildInvoiceDoc,
exportInvoicesV1,
invoiceExportName,
LINE_CSV_HEADERS,
loadInvoiceDocs,
rateString,
rupeesToPaise,
toInvoiceJson,
toLineItemsCsv,
type InvoiceExportDeps,
} from "./invoiceExportV1";
import type { Payment } from "./payments";
import { blankClient, type Client, type Invoice } from "./types";
const VENDOR = (patch: Record<string, unknown> = {}) =>
JSON.stringify({
vendorName: "Test Vendor",
vendorAddress: "1 Main Rd\nPune",
vendorEmail: "a@example.com",
vendorPhone: "999",
vendorPan: "AAPFU0939F",
vendorGstin: "27AAPFU0939F1ZV",
vendorStateCode: "27",
logoPath: "/secret/logo.png",
signaturePath: "/secret/sig.png",
gstRegistration: "regular",
signatoryName: "Sample",
...patch,
});
const invoice = (patch: Partial<Invoice> = {}): Invoice => ({
id: 1,
number: "INV/2026-001",
seriesId: 1,
invoiceDate: "2026-04-01",
dueDate: "2026-05-01",
clientId: 5,
clientName: "Client Ltd",
clientAddress: "12 MG Road\nBengaluru 560001",
clientGstin: "29AABCU9603R1ZJ",
poNumber: "PO-9",
placeOfSupplyStateCode: "29",
subtotal: 7310.1,
discount: 10.1,
taxType: "igst",
taxRate: 18,
cgstAmount: 0,
sgstAmount: 0,
igstAmount: 1314,
total: 8614,
amountInWords: "Rupees Eight Thousand Six Hundred Fourteen Only",
bankAccountId: null,
bankSnapshot: "",
signaturePath: null,
notes: "Thank you",
status: "issued",
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
docType: "tax_invoice",
reverseCharge: false,
vendorSnapshot: VENDOR(),
snapshotOrigin: "issued",
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
renderPrefs: "",
items: [
{ id: 1, description: "Design", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 5000.1, sortOrder: 0, hsnSac: "998391" },
{ id: 2, description: "Consulting", mode: "rate", rate: 1155.05, unit: "hour", quantity: 2, amount: 2310, sortOrder: 1, hsnSac: "" },
],
totalPaise: 861400,
...patch,
});
const client: Client = {
...blankClient(),
id: 5,
name: "Client Ltd",
stateCode: "29",
addressLine1: "12 MG Road",
addressLine2: "",
city: "Bengaluru",
pincode: "560001",
gstCategory: "registered_regular",
};
const payment = (patch: Partial<Payment> = {}): Payment => ({
id: 1,
invoiceId: 1,
paidOn: "2026-04-10",
amountPaise: 700000,
tdsPaise: 70000,
mode: "bank_transfer",
reference: "UTR123",
notes: "n",
createdAt: "x",
...patch,
});
describe("voiced.invoice.v1 document", () => {
it("matches the golden file for a fixture invoice", () => {
const text = toInvoiceJson([buildInvoiceDoc(invoice(), [payment()], client)]);
const path = new URL("./__golden__/invoice-v1.golden.json", import.meta.url);
if (!existsSync(path)) writeFileSync(path, text);
expect(text).toBe(readFileSync(path, "utf8"));
});
it("is deterministic and keeps the key order", () => {
const a = toInvoiceJson([buildInvoiceDoc(invoice(), [payment()], client)]);
const b = toInvoiceJson([buildInvoiceDoc(invoice(), [payment()], client)]);
expect(a).toBe(b);
const doc = buildInvoiceDoc(invoice(), [], null);
expect(Object.keys(doc).slice(0, 4)).toEqual(["schema", "number", "issue_date", "due_date"]);
expect(doc.schema).toBe("voiced.invoice.v1");
});
it("carries every amount as exact paise and a matching decimal string", () => {
const doc = buildInvoiceDoc(invoice(), [payment()], client);
// Plain float multiplication is off (0.29 * 100 is 28.999999999999996); the paise must be exact integers.
expect(0.29 * 100).not.toBe(29);
expect(doc.totals.subtotal_paise).toBe(731010);
expect(doc.totals.subtotal).toBe("7310.10");
expect(doc.totals.discount).toBe("10.10");
expect(doc.totals.taxable_value_paise).toBe(730000);
expect(doc.totals.tax).toBe("1314.00");
expect(doc.totals.total_paise).toBe(861400);
expect(doc.totals.total).toBe("8614.00");
expect(doc.items[0]).toMatchObject({ quantity: "1", unit: null, rate: "5000.10", taxable_value_paise: 500010, hsn_sac: "998391" });
expect(doc.items[1]).toMatchObject({ quantity: "2", unit: "hour", rate: "1155.05", rate_paise: 115505, hsn_sac: null });
expect(doc.payments[0]).toMatchObject({ amount: "7000.00", tds: "700.00", tds_paise: 70000 });
// Every *_paise field is a safe integer and agrees with its decimal.
const walk = (o: unknown, path: string[] = []) => {
if (!o || typeof o !== "object") return;
const rec = o as Record<string, unknown>;
for (const [k, v] of Object.entries(rec)) {
if (k.endsWith("_paise")) {
expect(Number.isSafeInteger(v), path.concat(k).join(".")).toBe(true);
const dec = rec[k.slice(0, -6)];
expect(dec, path.concat(k).join(".")).toBe(`${Math.trunc((v as number) / 100)}.${String(Math.abs(v as number) % 100).padStart(2, "0")}`);
} else walk(v, path.concat(k));
}
};
walk(JSON.parse(toInvoiceJson([doc])));
});
it("reports the tax heads: CGST+SGST at half rate, IGST whole, none for an unregistered vendor", () => {
const intra = buildInvoiceDoc(invoice({ taxType: "cgst_sgst", taxRate: 12.5, cgstAmount: 100.25, sgstAmount: 100.25, igstAmount: 0 }), [], client);
expect(intra.tax.cgst).toEqual({ rate: "6.25", amount_paise: 10025, amount: "100.25" });
expect(intra.tax.sgst).toEqual({ rate: "6.25", amount_paise: 10025, amount: "100.25" });
expect(intra.tax.igst).toEqual({ rate: "0", amount_paise: 0, amount: "0.00" });
expect(intra.tax.utgst.amount_paise).toBe(0);
expect(intra.totals.tax_paise).toBe(20050);
const inter = buildInvoiceDoc(invoice(), [], client);
expect(inter.tax.igst).toEqual({ rate: "18", amount_paise: 131400, amount: "1314.00" });
expect(inter.tax.cgst.rate).toBe("0");
});
it("moves the second head to UTGST when the supplier is in a union territory", () => {
const doc = buildInvoiceDoc(
invoice({ taxType: "cgst_sgst", taxRate: 18, cgstAmount: 90, sgstAmount: 90, igstAmount: 0, vendorSnapshot: VENDOR({ vendorStateCode: "04" }) }),
[],
client,
);
expect(doc.tax.sgst.amount_paise).toBe(0);
expect(doc.tax.utgst).toEqual({ rate: "9", amount_paise: 9000, amount: "90.00" });
expect(doc.supplier.state).toEqual({ code: "04", name: "Chandigarh" });
});
it("flags a cancelled invoice and keeps its figures", () => {
const doc = buildInvoiceDoc(invoice({ status: "cancelled", cancelledAt: "2026-04-05T10:00:00Z", cancelReason: "wrong client" }), [], client);
expect(doc).toMatchObject({ status: "cancelled", cancelled: true, cancelled_at: "2026-04-05T10:00:00Z", cancel_reason: "wrong client" });
expect(doc.totals.total).toBe("8614.00");
const live = buildInvoiceDoc(invoice(), [], client);
expect(live).toMatchObject({ cancelled: false, cancelled_at: null, cancel_reason: null });
});
it("exports an unregistered vendor's plain invoice without tax or GSTIN", () => {
const doc = buildInvoiceDoc(
invoice({
docType: "invoice",
taxType: "none",
taxRate: 0,
igstAmount: 0,
subtotal: 7300,
discount: 0,
total: 7300,
totalPaise: 730000,
clientGstin: "",
vendorSnapshot: VENDOR({ gstRegistration: "unregistered", vendorGstin: "" }),
}),
[],
null,
);
expect(doc.document_type).toBe("invoice");
expect(doc.supplier).toMatchObject({ gstin: null, gst_registration: "unregistered" });
expect(doc.totals.tax_paise).toBe(0);
expect(doc.tax.igst.rate).toBe("0");
expect(doc.client.gstin).toBeNull();
expect(doc.client.gst_category).toBe("unregistered");
expect(doc.client.address).toMatchObject({ line1: null, city: null, state: null });
});
it("does not leak local file paths from the supplier snapshot", () => {
const text = toInvoiceJson([buildInvoiceDoc(invoice(), [], client)]);
expect(text).not.toContain("/secret/");
expect(text).not.toContain("a@example.com");
});
it("survives a missing or broken supplier snapshot", () => {
for (const vendorSnapshot of ["", "not json", "null"]) {
const doc = buildInvoiceDoc(invoice({ vendorSnapshot }), [], client);
expect(doc.supplier).toMatchObject({ name: "", gstin: null, state: { code: "", name: "" } });
}
});
it("converts rupees and rates without noise", () => {
expect(rupeesToPaise(0.29)).toBe(29);
expect(rupeesToPaise(-0.001)).toBe(0);
expect(() => rupeesToPaise(Number.NaN)).toThrow();
expect(rateString(9)).toBe("9");
expect(rateString(2.5)).toBe("2.5");
expect(rateString(0.1 + 0.2)).toBe("0.3");
});
});
describe("line-items CSV", () => {
it("writes a BOM, CRLF, a header and one row per line with invoice amounts on the first row only", () => {
const csv = toLineItemsCsv([buildInvoiceDoc(invoice(), [], client)]);
expect(csv.startsWith("")).toBe(true);
const lines = csv.slice(1).split("\r\n");
expect(lines[0]).toBe(LINE_CSV_HEADERS.join(","));
expect(lines).toHaveLength(4); // header, 2 lines, trailing empty
const first = lines[1].split(",");
const second = lines[2].split(",");
const col = (name: string) => LINE_CSV_HEADERS.indexOf(name);
expect(first[col("invoice_total")]).toBe("8614.00");
expect(second[col("invoice_total")]).toBe("");
expect(first[col("invoice_igst")]).toBe("1314.00");
expect(second[col("taxable_value")]).toBe("2310.00");
expect(first[col("invoice_number")]).toBe("INV/2026-001");
});
it("quotes commas and quotes, and guards formulas", () => {
const doc = buildInvoiceDoc(invoice({ items: [{ id: 1, description: '=SUM(A1), "x"', mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 10, sortOrder: 0 }] }), [], client);
const csv = toLineItemsCsv([doc]);
expect(csv).toContain(`"'=SUM(A1), ""x"""`);
});
it("keeps an invoice with no items as one row", () => {
const csv = toLineItemsCsv([buildInvoiceDoc(invoice({ items: [] }), [], client)]);
expect(csv.slice(1).split("\r\n")).toHaveLength(3);
});
});
describe("saving", () => {
const deps = (over: Partial<InvoiceExportDeps> = {}): InvoiceExportDeps & { written: { path: string; text: string }[] } => {
const written: { path: string; text: string }[] = [];
return {
written,
getInvoice: async (id) => invoice({ id, number: `INV/2026-00${id}` }),
listPayments: async () => [payment()],
listClients: async () => [client],
getLastExportDir: async () => "/home/a/exports",
save: async ({ defaultPath }) => defaultPath,
writeExportFile: async (path, bytes) => {
written.push({ path, text: new TextDecoder().decode(bytes) });
return path;
},
now: () => new Date("2026-06-01T10:00:00Z"),
...over,
};
};
it("names the file after the invoice or the count", () => {
const one = [buildInvoiceDoc(invoice(), [], null)];
const now = new Date("2026-06-01T10:00:00Z");
expect(invoiceExportName(one, "json", now)).toBe("voiced-invoice-INV-2026-001.json");
expect(invoiceExportName(one, "csv", now)).toBe("voiced-invoice-INV-2026-001-lines.csv");
expect(invoiceExportName([...one, ...one], "json", now)).toBe("voiced-invoices-2-2026-06-01.json");
});
it("writes the chosen invoices in order, once each", async () => {
const d = deps();
const done = await exportInvoicesV1(d, [2, 1, 2], "json");
expect(done).toEqual({ path: "/home/a/exports/voiced-invoices-2-2026-06-01.json", count: 2 });
const docs = JSON.parse(d.written[0].text) as { number: string }[];
expect(docs.map((x) => x.number)).toEqual(["INV/2026-002", "INV/2026-001"]);
});
it("returns null when the dialog is cancelled and writes nothing", async () => {
const d = deps({ save: async () => null });
expect(await exportInvoicesV1(d, [1], "csv")).toBeNull();
expect(d.written).toHaveLength(0);
});
it("still exports when the client list cannot be read", async () => {
const docs = await loadInvoiceDocs(deps({ listClients: async () => Promise.reject(new Error("db")) }), [1]);
expect(docs).toHaveLength(1);
expect(docs[0].client.address.line1).toBeNull();
});
it("works without a remembered folder", async () => {
const d = deps({ getLastExportDir: async () => Promise.reject(new Error("none")) });
const done = await exportInvoicesV1(d, [1], "json");
expect(done?.path).toBe("voiced-invoice-INV-2026-001.json");
});
});
+364
View File
@@ -0,0 +1,364 @@
/**
* The generic `voiced.invoice.v1` export: a canonical, deterministic JSON document per invoice (and a CSV of line
* items) for software that Voiced has no direct integration with. Pure: it formats data it is handed, using integer
* paise for every amount, and never touches Tauri. The format is described in docs/voiced-invoice-v1.md.
*/
import { csvField, guardFormula, joinPath, paiseToDecimal } from "./historyExport";
import type { Payment } from "./payments";
import { INDIAN_STATES, type Client, type Invoice } from "./types";
export const INVOICE_SCHEMA = "voiced.invoice.v1";
/** Union territories without a legislature (mirror of `is_ut_without_legislature` in gst.rs): their second head is UTGST. */
const UT_WITHOUT_LEGISLATURE = new Set(["04", "26", "31", "35", "38"]);
export interface TaxHeadV1 {
/** Percent, e.g. "9" or "2.5"; "0" when the head does not apply to the invoice. */
rate: string;
amount_paise: number;
amount: string;
}
export interface InvoiceDocV1 {
schema: typeof INVOICE_SCHEMA;
number: string;
issue_date: string;
due_date: string | null;
document_type: "tax_invoice" | "invoice";
status: string;
cancelled: boolean;
cancelled_at: string | null;
cancel_reason: string | null;
currency: "INR";
po_number: string | null;
supplier: {
name: string;
address: string;
gstin: string | null;
pan: string | null;
gst_registration: string;
state: { code: string; name: string };
};
client: {
name: string;
gstin: string | null;
gst_category: string;
address: {
text: string;
line1: string | null;
line2: string | null;
city: string | null;
pincode: string | null;
state: { code: string; name: string } | null;
};
place_of_supply: { code: string; name: string };
};
items: {
line: number;
description: string;
hsn_sac: string | null;
quantity: string;
unit: string | null;
rate_paise: number;
rate: string;
taxable_value_paise: number;
taxable_value: string;
}[];
tax: { cgst: TaxHeadV1; sgst: TaxHeadV1; utgst: TaxHeadV1; igst: TaxHeadV1 };
totals: {
subtotal_paise: number;
subtotal: string;
discount_paise: number;
discount: string;
taxable_value_paise: number;
taxable_value: string;
tax_paise: number;
tax: string;
total_paise: number;
total: string;
amount_in_words: string | null;
};
reverse_charge: boolean;
notes: string | null;
payments: {
date: string;
amount_paise: number;
amount: string;
tds_paise: number;
tds: string;
mode: string;
reference: string | null;
}[];
}
/** Rupees as stored (a REAL with two decimals) to integer paise, rounded exactly as the backend does. */
export function rupeesToPaise(rupees: number): number {
const paise = Math.round(rupees * 100);
if (!Number.isSafeInteger(paise)) throw new Error(`Not an amount: ${rupees}`);
return paise === 0 ? 0 : paise; // no negative zero
}
const blank = (s: string | null | undefined): string | null => {
const t = (s ?? "").trim();
return t === "" ? null : t;
};
const gstinOf = (s: string | null | undefined): string | null => {
const t = blank(s);
return t === null || t.toUpperCase() === "NA" ? null : t;
};
const stateOf = (code: string): { code: string; name: string } => ({
code,
name: INDIAN_STATES.find((s) => s.code === code)?.name ?? "",
});
/** A percentage as the shortest decimal string (9, 6.25), without binary noise. */
export function rateString(rate: number): string {
return String(Number(rate.toFixed(4)));
}
interface Snapshot {
vendorName?: string;
vendorAddress?: string;
vendorGstin?: string;
vendorPan?: string;
vendorStateCode?: string;
gstRegistration?: string;
}
function parseSnapshot(text: string): Snapshot {
try {
const v: unknown = text ? JSON.parse(text) : {};
return v && typeof v === "object" ? (v as Snapshot) : {};
} catch {
return {};
}
}
const head = (rate: number, rupees: number): TaxHeadV1 => {
const paise = rupeesToPaise(rupees);
return { rate: rateString(rate), amount_paise: paise, amount: paiseToDecimal(paise) };
};
/**
* One invoice as `voiced.invoice.v1`. The supplier comes from the snapshot frozen at issue. The client's name, GSTIN
* and place of supply are frozen on the invoice too; the structured address and GST category are read from the saved
* client record (`client`, optional) because the invoice only froze the printed text.
*/
export function buildInvoiceDoc(invoice: Invoice, payments: Payment[], client?: Client | null): InvoiceDocV1 {
const snap = parseSnapshot(invoice.vendorSnapshot);
const supplierState = snap.vendorStateCode ?? "";
const isUt = UT_WITHOUT_LEGISLATURE.has(supplierState);
const half = invoice.taxType === "cgst_sgst" ? invoice.taxRate / 2 : 0;
const igstRate = invoice.taxType === "igst" ? invoice.taxRate : 0;
const zero = (): TaxHeadV1 => head(0, 0);
const secondHead = head(half, invoice.sgstAmount);
const tax = {
cgst: head(half, invoice.cgstAmount),
sgst: isUt ? zero() : secondHead,
utgst: isUt ? secondHead : zero(),
igst: head(igstRate, invoice.igstAmount),
};
const subtotal = rupeesToPaise(invoice.subtotal);
const discount = rupeesToPaise(invoice.discount);
const totalTax = tax.cgst.amount_paise + tax.sgst.amount_paise + tax.utgst.amount_paise + tax.igst.amount_paise;
const total = invoice.totalPaise ?? rupeesToPaise(invoice.total);
const gstin = gstinOf(invoice.clientGstin);
const pos = invoice.placeOfSupplyStateCode;
const clientState = client && client.stateCode ? stateOf(client.stateCode) : null;
return {
schema: INVOICE_SCHEMA,
number: invoice.number,
issue_date: invoice.invoiceDate,
due_date: blank(invoice.dueDate),
document_type: invoice.docType,
status: invoice.status,
cancelled: invoice.status === "cancelled",
cancelled_at: invoice.status === "cancelled" ? blank(invoice.cancelledAt) : null,
cancel_reason: invoice.status === "cancelled" ? blank(invoice.cancelReason) : null,
currency: "INR",
po_number: blank(invoice.poNumber),
supplier: {
name: snap.vendorName ?? "",
address: snap.vendorAddress ?? "",
gstin: gstinOf(snap.vendorGstin),
pan: blank(snap.vendorPan),
gst_registration: snap.gstRegistration ?? "",
state: stateOf(supplierState),
},
client: {
name: invoice.clientName,
gstin,
gst_category: client?.gstCategory ?? (gstin ? "registered_regular" : "unregistered"),
address: {
text: invoice.clientAddress,
line1: blank(client?.addressLine1),
line2: blank(client?.addressLine2),
city: blank(client?.city),
pincode: blank(client?.pincode),
state: clientState,
},
place_of_supply: stateOf(pos),
},
items: invoice.items.map((item, index) => {
const fixed = item.mode === "fixed";
const amount = rupeesToPaise(item.amount);
const rate = fixed ? amount : rupeesToPaise(item.rate);
return {
line: index + 1,
description: item.description,
hsn_sac: blank(item.hsnSac),
quantity: fixed ? "1" : String(item.quantity),
unit: fixed ? null : item.unit,
rate_paise: rate,
rate: paiseToDecimal(rate),
taxable_value_paise: amount,
taxable_value: paiseToDecimal(amount),
};
}),
tax,
totals: {
subtotal_paise: subtotal,
subtotal: paiseToDecimal(subtotal),
discount_paise: discount,
discount: paiseToDecimal(discount),
taxable_value_paise: subtotal - discount,
taxable_value: paiseToDecimal(subtotal - discount),
tax_paise: totalTax,
tax: paiseToDecimal(totalTax),
total_paise: total,
total: paiseToDecimal(total),
amount_in_words: blank(invoice.amountInWords),
},
reverse_charge: invoice.reverseCharge,
notes: blank(invoice.notes),
payments: payments.map((p) => ({
date: p.paidOn,
amount_paise: p.amountPaise,
amount: paiseToDecimal(p.amountPaise),
tds_paise: p.tdsPaise,
tds: paiseToDecimal(p.tdsPaise),
mode: p.mode,
reference: blank(p.reference),
})),
};
}
/** The JSON file: an array of invoice documents in the given order. Same input, same bytes. */
export function toInvoiceJson(docs: InvoiceDocV1[]): string {
return `${JSON.stringify(docs, null, 2)}\n`;
}
// ---- CSV: one row per line item ----
type Kind = "text" | "amount";
const LINE_COLUMNS: readonly { header: string; kind: Kind; value: (d: InvoiceDocV1, line: number) => string }[] = [
{ header: "invoice_number", kind: "text", value: (d) => d.number },
{ header: "issue_date", kind: "text", value: (d) => d.issue_date },
{ header: "due_date", kind: "text", value: (d) => d.due_date ?? "" },
{ header: "document_type", kind: "text", value: (d) => d.document_type },
{ header: "status", kind: "text", value: (d) => d.status },
{ header: "cancelled", kind: "text", value: (d) => (d.cancelled ? "yes" : "no") },
{ header: "currency", kind: "text", value: (d) => d.currency },
{ header: "supplier_name", kind: "text", value: (d) => d.supplier.name },
{ header: "supplier_gstin", kind: "text", value: (d) => d.supplier.gstin ?? "" },
{ header: "client_name", kind: "text", value: (d) => d.client.name },
{ header: "client_gstin", kind: "text", value: (d) => d.client.gstin ?? "" },
{ header: "client_gst_category", kind: "text", value: (d) => d.client.gst_category },
{ header: "place_of_supply_code", kind: "text", value: (d) => d.client.place_of_supply.code },
{ header: "place_of_supply", kind: "text", value: (d) => d.client.place_of_supply.name },
{ header: "reverse_charge", kind: "text", value: (d) => (d.reverse_charge ? "yes" : "no") },
{ header: "line", kind: "text", value: (d, i) => (d.items[i] ? String(d.items[i].line) : "") },
{ header: "description", kind: "text", value: (d, i) => d.items[i]?.description ?? "" },
{ header: "hsn_sac", kind: "text", value: (d, i) => d.items[i]?.hsn_sac ?? "" },
{ header: "quantity", kind: "text", value: (d, i) => d.items[i]?.quantity ?? "" },
{ header: "unit", kind: "text", value: (d, i) => d.items[i]?.unit ?? "" },
{ header: "rate", kind: "amount", value: (d, i) => d.items[i]?.rate ?? "" },
{ header: "taxable_value", kind: "amount", value: (d, i) => d.items[i]?.taxable_value ?? "" },
// Invoice-level amounts sit on the first row only, so summing a column never counts an invoice twice.
{ header: "invoice_discount", kind: "amount", value: (d, i) => (i === 0 ? d.totals.discount : "") },
{ header: "invoice_cgst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.cgst.amount : "") },
{ header: "invoice_sgst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.sgst.amount : "") },
{ header: "invoice_utgst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.utgst.amount : "") },
{ header: "invoice_igst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.igst.amount : "") },
{ header: "invoice_total", kind: "amount", value: (d, i) => (i === 0 ? d.totals.total : "") },
];
export const LINE_CSV_HEADERS: readonly string[] = LINE_COLUMNS.map((c) => c.header);
/** UTF-8 BOM, CRLF, header row always present; text cells are guarded against spreadsheet formulas. */
export function toLineItemsCsv(docs: InvoiceDocV1[]): string {
const lines: string[] = [LINE_COLUMNS.map((c) => csvField(c.header)).join(",")];
for (const doc of docs) {
const rows = Math.max(1, doc.items.length);
for (let i = 0; i < rows; i++) {
lines.push(LINE_COLUMNS.map((c) => csvField(c.kind === "text" ? guardFormula(c.value(doc, i)) : c.value(doc, i))).join(","));
}
}
return `${lines.join("\r\n")}\r\n`;
}
// ---- saving ----
export type InvoiceExportFormat = "json" | "csv";
const safeName = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-");
export function invoiceExportName(docs: InvoiceDocV1[], format: InvoiceExportFormat, now: Date): string {
const base =
docs.length === 1
? `voiced-invoice-${safeName(docs[0].number)}`
: `voiced-invoices-${docs.length}-${now.toISOString().slice(0, 10)}`;
return `${base}${format === "csv" ? "-lines" : ""}.${format}`;
}
export interface InvoiceExportDeps {
getInvoice(id: number): Promise<Invoice>;
listPayments(invoiceId: number): Promise<Payment[]>;
listClients(): Promise<Client[]>;
getLastExportDir(): Promise<string>;
save(opts: { defaultPath: string; format: InvoiceExportFormat }): Promise<string | null>;
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
now(): Date;
}
/** Reads the invoices (frozen data plus payments) in the order given; the client record is optional detail. */
export async function loadInvoiceDocs(deps: Pick<InvoiceExportDeps, "getInvoice" | "listPayments" | "listClients">, ids: number[]): Promise<InvoiceDocV1[]> {
const unique = [...new Set(ids)];
const loaded: { invoice: Invoice; payments: Payment[] }[] = [];
for (const id of unique) {
const [invoice, payments] = await Promise.all([deps.getInvoice(id), deps.listPayments(id)]);
loaded.push({ invoice, payments });
}
let clients = new Map<number, Client>();
if (loaded.some((l) => l.invoice.clientId !== null)) {
try {
clients = new Map((await deps.listClients()).filter((c) => c.id !== null).map((c) => [c.id as number, c]));
} catch {
// The structured address is extra detail; the export is complete without it.
}
}
return loaded.map(({ invoice, payments }) => buildInvoiceDoc(invoice, payments, invoice.clientId === null ? null : (clients.get(invoice.clientId) ?? null)));
}
/** Exports the chosen invoices through the save dialog. Returns the path, or null if the dialog was cancelled. */
export async function exportInvoicesV1(deps: InvoiceExportDeps, ids: number[], format: InvoiceExportFormat): Promise<{ path: string; count: number } | null> {
const docs = await loadInvoiceDocs(deps, ids);
const text = format === "csv" ? toLineItemsCsv(docs) : toInvoiceJson(docs);
let dir = "";
try {
dir = await deps.getLastExportDir();
} catch {
// No remembered folder: the dialog opens wherever the system prefers.
}
const chosen = await deps.save({ defaultPath: joinPath(dir, invoiceExportName(docs, format, deps.now())), format });
if (!chosen) return null;
const path = (await deps.writeExportFile(chosen, new TextEncoder().encode(text))) || chosen;
return { path, count: docs.length };
}
+3 -1
View File
@@ -23,9 +23,11 @@ export interface Payment {
reference: string; reference: string;
notes: string; notes: string;
createdAt: string; createdAt: string;
/** Payment Entry name on ERPNext once the payment was sent. */
erpnextPaymentEntry?: string | null;
} }
export type PaymentInput = Omit<Payment, "id" | "createdAt">; export type PaymentInput = Omit<Payment, "id" | "createdAt" | "erpnextPaymentEntry">;
type TagType = "red" | "green" | "blue" | "gray" | "warm-gray" | "cool-gray" | "purple"; type TagType = "red" | "green" | "blue" | "gray" | "warm-gray" | "cool-gray" | "purple";
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, saveSettingsTab } from "./settingsTab"; import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, requestSettingsTab, saveSettingsTab } from "./settingsTab";
describe("settings tab persistence", () => { describe("settings tab persistence", () => {
it("maps stored ids to indexes and falls back to the first tab", () => { it("maps stored ids to indexes and falls back to the first tab", () => {
@@ -14,4 +14,10 @@ describe("settings tab persistence", () => {
expect(() => saveSettingsTab(2)).not.toThrow(); expect(() => saveSettingsTab(2)).not.toThrow();
expect(() => saveSettingsTab(99)).not.toThrow(); expect(() => saveSettingsTab(99)).not.toThrow();
}); });
it("honours a requested tab once, then goes back to the stored one", () => {
requestSettingsTab("integrations");
expect(loadSettingsTab()).toBe(SETTINGS_TABS.findIndex((t) => t.id === "integrations"));
expect(loadSettingsTab()).toBe(0);
});
}); });
+14
View File
@@ -9,6 +9,7 @@ export const SETTINGS_TABS = [
{ id: "templates", label: "Templates & export" }, { id: "templates", label: "Templates & export" },
{ id: "fonts", label: "Fonts" }, { id: "fonts", label: "Fonts" },
{ id: "data", label: "Data" }, { id: "data", label: "Data" },
{ id: "integrations", label: "Integrations" },
] as const; ] as const;
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"]; export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
@@ -19,8 +20,21 @@ export function parseSettingsTab(value: string | null | undefined): number {
return index < 0 ? 0 : index; return index < 0 ? 0 : index;
} }
/** A tab another screen asked for; used once by the next Settings mount, so it works without localStorage. */
let requested: SettingsTabId | null = null;
/** Makes the next opening of Settings land on `id` (for "Set up in Settings" links). */
export function requestSettingsTab(id: SettingsTabId): void {
requested = id;
}
/** The last Settings tab this viewer used. A per-viewer convenience only. */ /** The last Settings tab this viewer used. A per-viewer convenience only. */
export function loadSettingsTab(): number { export function loadSettingsTab(): number {
if (requested) {
const index = parseSettingsTab(requested);
requested = null;
return index;
}
try { try {
return parseSettingsTab(localStorage.getItem(KEY)); return parseSettingsTab(localStorage.getItem(KEY));
} catch { } catch {
+73
View File
@@ -698,3 +698,76 @@ body {
.voiced-detail__unarchived p { .voiced-detail__unarchived p {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* Settings, Integrations, ERPNext */
.voiced-erp__section > h3 {
margin-bottom: 0.5rem;
}
.voiced-erp__fields {
display: grid;
gap: 1rem;
max-width: 40rem;
margin-top: 1rem;
}
.voiced-erp__label {
font-size: 0.75rem;
color: var(--cds-text-secondary);
margin-bottom: 0.25rem;
}
.voiced-erp__secret > * + * {
margin-top: 0.5rem;
}
.voiced-erp__result > * + * {
margin-top: 0.75rem;
}
.voiced-erp__uoms {
border: 0;
padding: 0;
display: grid;
gap: 1rem;
}
.voiced-erp__uoms legend {
font-size: 0.75rem;
color: var(--cds-text-secondary);
}
.voiced-erp__error {
color: var(--cds-text-error);
font-size: 0.75rem;
}
/* ERPNext column and bulk-send bar on the invoices list */
.voiced-history__bulk {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.voiced-erp__results {
list-style: none;
margin-top: 1rem;
max-height: 16rem;
overflow: auto;
}
.voiced-erp__results li {
padding: 0.25rem 0;
border-bottom: 1px solid var(--cds-border-subtle);
}
.voiced-erp__panel {
border: 1px solid var(--cds-border-subtle);
padding: 1rem;
}
.voiced-erp__panel > * + * {
margin-top: 0.75rem;
}
+6 -1
View File
@@ -41,6 +41,7 @@ import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding"; import { LogoBranding } from "../components/LogoBranding";
import DataBackupPanel from "../components/DataBackupPanel"; import DataBackupPanel from "../components/DataBackupPanel";
import DiagnosticsPanel from "../components/DiagnosticsPanel"; import DiagnosticsPanel from "../components/DiagnosticsPanel";
import ErpnextSettingsPanel from "../components/ErpnextSettingsPanel";
import { FontOverrideTable } from "../components/FontOverrideTable"; import { FontOverrideTable } from "../components/FontOverrideTable";
import { useToast } from "../components/ToastProvider"; import { useToast } from "../components/ToastProvider";
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab"; import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
@@ -159,7 +160,7 @@ export default function AppSettings({
return ( return (
<div className="voiced-page"> <div className="voiced-page">
<h1 className="voiced-page__title">Settings</h1> <h1 className="voiced-page__title">Settings</h1>
<p className="voiced-page__subtitle">Business details, branding, defaults and bank accounts.</p> <p className="voiced-page__subtitle">Business details, branding, defaults, bank accounts and integrations.</p>
<Tabs selectedIndex={tab} onChange={({ selectedIndex }) => selectTab(selectedIndex)}> <Tabs selectedIndex={tab} onChange={({ selectedIndex }) => selectTab(selectedIndex)}>
<TabList aria-label="Settings sections" contained scrollIntoView> <TabList aria-label="Settings sections" contained scrollIntoView>
@@ -171,6 +172,7 @@ export default function AppSettings({
<Tab>Templates &amp; export</Tab> <Tab>Templates &amp; export</Tab>
<Tab>Fonts</Tab> <Tab>Fonts</Tab>
<Tab>Data</Tab> <Tab>Data</Tab>
<Tab>Integrations</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel> <TabPanel>
@@ -516,6 +518,9 @@ export default function AppSettings({
<TabPanel> <TabPanel>
<DataBackupPanel /> <DataBackupPanel />
</TabPanel> </TabPanel>
<TabPanel>
<ErpnextSettingsPanel active={tab === SETTINGS_TABS.findIndex((t) => t.id === "integrations")} />
</TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>
+217 -3
View File
@@ -3,6 +3,7 @@ import {
Button, Button,
InlineLoading, InlineLoading,
InlineNotification, InlineNotification,
Link,
Modal, Modal,
OverflowMenu, OverflowMenu,
OverflowMenuItem, OverflowMenuItem,
@@ -28,6 +29,20 @@ import { toDraftPayload } from "../lib/draft";
import { invoiceToFormState } from "../lib/duplicate"; import { invoiceToFormState } from "../lib/duplicate";
import type { ExportMode } from "../lib/filename"; import type { ExportMode } from "../lib/filename";
import { useInvoiceExport } from "../hooks/useInvoiceExport"; import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { useGenericExport } from "../hooks/useGenericExport";
import { openInErpnext } from "../lib/erpnextActions";
import type { ErpnextConfig, ErpnextPushResult, ErpnextSyncStatus } from "../lib/erpnext";
import {
canOpenInErpnext,
describePaymentPush,
describePush,
failedPush,
formatSyncedAt,
isConfigured,
paymentPushDisabledReason,
pushDisabledReason,
syncTag,
} from "../lib/erpnextUi";
import { useReturnFocus } from "../hooks/useReturnFocus"; import { useReturnFocus } from "../hooks/useReturnFocus";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import PdfPreview from "../components/PdfPreview"; import PdfPreview from "../components/PdfPreview";
@@ -41,6 +56,8 @@ interface Props {
onBack: () => void; onBack: () => void;
/** A draft was created from this invoice; the shell opens it in the New invoice form. */ /** A draft was created from this invoice; the shell opens it in the New invoice form. */
onDuplicated: (draftId: number) => void; onDuplicated: (draftId: number) => void;
/** Goes to Settings, Integrations. */
onOpenSettings: () => void;
} }
interface Frozen { interface Frozen {
@@ -74,7 +91,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
); );
} }
export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated }: Props) { export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated, onOpenSettings }: Props) {
const toast = useToast(); const toast = useToast();
const [invoice, setInvoice] = useState<Invoice | null>(null); const [invoice, setInvoice] = useState<Invoice | null>(null);
const [payments, setPayments] = useState<Payment[]>([]); const [payments, setPayments] = useState<Payment[]>([]);
@@ -88,6 +105,12 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
const [cancelOpen, setCancelOpen] = useState(false); const [cancelOpen, setCancelOpen] = useState(false);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [duplicating, setDuplicating] = useState(false); const [duplicating, setDuplicating] = useState(false);
const [erpCfg, setErpCfg] = useState<ErpnextConfig | null>(null);
const [erpSync, setErpSync] = useState<ErpnextSyncStatus | undefined>(undefined);
const [lastPush, setLastPush] = useState<ErpnextPushResult | null>(null);
const [sendingErp, setSendingErp] = useState(false);
const [sendingPayment, setSendingPayment] = useState<number | null>(null);
const generic = useGenericExport();
useReturnFocus(deleteTarget !== null); useReturnFocus(deleteTarget !== null);
useReturnFocus(cancelOpen); useReturnFocus(cancelOpen);
@@ -123,14 +146,31 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
} }
}, []); }, []);
/** ERPNext is optional: when it cannot be read the panel just stays out of the way. */
const loadErp = useCallback(async (id: number, gen: number) => {
try {
const [cfg, sync] = await Promise.all([api.erpnextGetConfig(), api.erpnextSyncStatus(id)]);
if (gen !== generation.current) return;
setErpCfg(cfg);
setErpSync(sync);
} catch {
if (gen === generation.current) {
setErpCfg(null);
setErpSync(undefined);
}
}
}, []);
useEffect(() => { useEffect(() => {
const gen = ++generation.current; const gen = ++generation.current;
setInvoice(null); setInvoice(null);
setPdf(null); setPdf(null);
setArchive(null); setArchive(null);
setLastPush(null);
void loadData(invoiceId, gen); void loadData(invoiceId, gen);
void loadPdf(invoiceId, gen); void loadPdf(invoiceId, gen);
}, [invoiceId, loadData, loadPdf]); void loadErp(invoiceId, gen);
}, [invoiceId, loadData, loadPdf, loadErp]);
const refresh = () => loadData(invoiceId, generation.current); const refresh = () => loadData(invoiceId, generation.current);
@@ -194,6 +234,44 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
} }
}; };
/** `submit` undefined keeps what the invoice already is in ERPNext. A failure is shown, never thrown. */
const onSendErp = async (submit: boolean) => {
if (!invoice) return;
setSendingErp(true);
let result: ErpnextPushResult;
try {
result = await api.erpnextPushInvoice(invoice.id, submit);
} catch (e) {
result = failedPush(invoice.id, invoice.number, e);
}
setLastPush(result);
const note = describePush(result);
toast[note.kind](note.title, note.subtitle);
await loadErp(invoiceId, generation.current);
setSendingErp(false);
};
const onOpenErp = async () => {
try {
await openInErpnext(invoiceId);
} catch (e) {
toast.error("Could not open ERPNext", String(e));
}
};
const onSendPayment = async (paymentId: number) => {
setSendingPayment(paymentId);
try {
const note = describePaymentPush(await api.erpnextPushPayment(paymentId));
toast[note.kind](note.title, note.subtitle);
await refresh();
} catch (e) {
toast.error("Could not send the payment", String(e));
} finally {
setSendingPayment(null);
}
};
const onDeletePayment = async () => { const onDeletePayment = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
const target = deleteTarget; const target = deleteTarget;
@@ -231,6 +309,12 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
const balance = invoice.balancePaise ?? 0; const balance = invoice.balancePaise ?? 0;
const canPay = invoice.status === "issued" && balance > 0; const canPay = invoice.status === "issued" && balance > 0;
const half = invoice.taxRate / 2; const half = invoice.taxRate / 2;
const erpOn = isConfigured(erpCfg);
const erpTag = syncTag(erpSync);
const erpBlocked = pushDisabledReason({ cfg: erpCfg, vendorRegistered: settings.gstRegistration !== "unregistered", invoiceStatus: invoice.status });
const erpHasState = Boolean(erpSync && erpSync.status !== "none");
const erpSubmitted = erpSync?.status === "synced" && erpSync.remoteDocstatus === 1;
const showPaymentErp = erpOn || payments.some((p) => p.erpnextPaymentEntry);
return ( return (
<div className="voiced-page voiced-detail"> <div className="voiced-page voiced-detail">
@@ -284,6 +368,8 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
</Button> </Button>
<OverflowMenu aria-label="More actions" iconDescription="More actions" flipped size="md" disabled={busy}> <OverflowMenu aria-label="More actions" iconDescription="More actions" flipped size="md" disabled={busy}>
<OverflowMenuItem itemText="Re-render with current template" onClick={() => onExport("searchable", "rerender")} /> <OverflowMenuItem itemText="Re-render with current template" onClick={() => onExport("searchable", "rerender")} />
<OverflowMenuItem itemText="Export as voiced.invoice.v1 (JSON)" hasDivider onClick={() => void generic.run([invoice.id], "json")} />
<OverflowMenuItem itemText="Export line items (CSV)" onClick={() => void generic.run([invoice.id], "csv")} />
{invoice.status === "issued" ? ( {invoice.status === "issued" ? (
<OverflowMenuItem <OverflowMenuItem
itemText={payments.length > 0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"} itemText={payments.length > 0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"}
@@ -416,6 +502,104 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
{invoice.notes ? <p className="voiced-detail__pre voiced-muted">{invoice.notes}</p> : null} {invoice.notes ? <p className="voiced-detail__pre voiced-muted">{invoice.notes}</p> : null}
</section> </section>
<section aria-labelledby="detail-erpnext">
<h2 id="detail-erpnext" className="voiced-detail__h2">
ERPNext
</h2>
{!erpOn && !erpHasState ? (
<p className="voiced-muted">
ERPNext is not set up.{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Set it up in Settings, Integrations
</Link>
.
</p>
) : (
<div className="voiced-erp__panel">
<dl>
<Field label="Status">
<Tag type={erpTag.type} size="sm" title={erpTag.tooltip}>
{erpTag.label}
</Tag>
</Field>
{erpSync?.remoteName ? <Field label="ERPNext document">{erpSync.remoteName}</Field> : null}
{erpSync?.syncedAt ? <Field label="Last synced">{formatSyncedAt(erpSync.syncedAt)}</Field> : null}
{erpSync?.status === "synced" ? <Field label="PDF">{erpSync.attached ? "Attached in ERPNext" : "Not attached"}</Field> : null}
</dl>
{erpSync && (erpSync.status === "error" || erpSync.status === "conflict") && erpSync.lastError ? (
<InlineNotification
kind={erpSync.status === "conflict" ? "warning" : "error"}
lowContrast
hideCloseButton
title={erpSync.status === "conflict" ? "Conflict with ERPNext" : "The last send failed"}
subtitle={erpSync.lastError}
/>
) : null}
{lastPush && lastPush.warnings.length > 0 ? (
<InlineNotification kind="warning" lowContrast hideCloseButton title="Sent with notes" subtitle={lastPush.warnings.join(" ")} />
) : null}
{erpOn ? (
<>
<div className="voiced-header-action">
{erpSubmitted ? (
<Button kind="tertiary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(true)}>
{sendingErp ? "Sending…" : "Re-send"}
</Button>
) : (
<>
<Button kind="tertiary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(false)}>
{sendingErp ? "Sending…" : erpHasState ? "Re-send as draft" : "Send as draft"}
</Button>
<Button kind="primary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(true)}>
Send &amp; submit
</Button>
</>
)}
{canOpenInErpnext(erpSync) ? (
<Button kind="ghost" size="sm" onClick={() => void onOpenErp()}>
Open in ERPNext
</Button>
) : null}
</div>
{erpBlocked ? (
<p className="voiced-muted">
{erpBlocked}{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Open Settings
</Link>
</p>
) : null}
</>
) : (
<p className="voiced-muted">
ERPNext is not set up any more, so nothing can be sent.{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Open Settings
</Link>
</p>
)}
</div>
)}
</section>
<section aria-labelledby="detail-payments"> <section aria-labelledby="detail-payments">
<h2 id="detail-payments" className="voiced-detail__h2"> <h2 id="detail-payments" className="voiced-detail__h2">
Payments Payments
@@ -432,6 +616,7 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
<TableHeader>Reference</TableHeader> <TableHeader>Reference</TableHeader>
<TableHeader className="voiced-num">Received</TableHeader> <TableHeader className="voiced-num">Received</TableHeader>
<TableHeader className="voiced-num">TDS</TableHeader> <TableHeader className="voiced-num">TDS</TableHeader>
{showPaymentErp ? <TableHeader>ERPNext</TableHeader> : null}
<TableHeader> <TableHeader>
<span className="cds--assistive-text">Actions</span> <span className="cds--assistive-text">Actions</span>
</TableHeader> </TableHeader>
@@ -448,6 +633,35 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
</TableCell> </TableCell>
<TableCell className="voiced-num">{formatPaise(p.amountPaise)}</TableCell> <TableCell className="voiced-num">{formatPaise(p.amountPaise)}</TableCell>
<TableCell className="voiced-num">{formatPaise(p.tdsPaise)}</TableCell> <TableCell className="voiced-num">{formatPaise(p.tdsPaise)}</TableCell>
{showPaymentErp ? (
<TableCell>
{p.erpnextPaymentEntry ? (
<>
<Tag type="green" size="sm">
Sent
</Tag>{" "}
<span className="voiced-muted">{p.erpnextPaymentEntry}</span>
</>
) : (
(() => {
const reason = paymentPushDisabledReason({ cfg: erpCfg, sync: erpSync, entry: p.erpnextPaymentEntry });
return (
<>
<Button
kind="ghost"
size="sm"
disabled={reason !== null || sendingPayment !== null}
onClick={() => void onSendPayment(p.id)}
>
{sendingPayment === p.id ? "Sending…" : "Send payment to ERPNext"}
</Button>
{reason ? <div className="voiced-muted">{reason}</div> : null}
</>
);
})()
)}
</TableCell>
) : null}
<TableCell> <TableCell>
<Button <Button
kind="ghost" kind="ghost"
@@ -507,7 +721,7 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
> >
<p> <p>
{deleteTarget {deleteTarget
? `${formatPaise(deleteTarget.amountPaise + deleteTarget.tdsPaise)} (cash ${formatPaise(deleteTarget.amountPaise)}, TDS ${formatPaise(deleteTarget.tdsPaise)}) received on ${formatDate(deleteTarget.paidOn)}. The invoice balance goes back up by that amount.` ? `${formatPaise(deleteTarget.amountPaise + deleteTarget.tdsPaise)} (cash ${formatPaise(deleteTarget.amountPaise)}, TDS ${formatPaise(deleteTarget.tdsPaise)}) received on ${formatDate(deleteTarget.paidOn)}. The invoice balance goes back up by that amount.${deleteTarget.erpnextPaymentEntry ? ` It was sent to ERPNext as ${deleteTarget.erpnextPaymentEntry}; that entry is not removed there.` : ""}`
: ""} : ""}
</p> </p>
</Modal> </Modal>
+160 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Button, Button,
Checkbox,
InlineLoading, InlineLoading,
Link, Link,
OverflowMenu, OverflowMenu,
@@ -43,6 +44,11 @@ import {
import type { ExportMode } from "../lib/filename"; import type { ExportMode } from "../lib/filename";
import type { ExportSource } from "../lib/exportFlow"; import type { ExportSource } from "../lib/exportFlow";
import { useInvoiceExport } from "../hooks/useInvoiceExport"; import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { useGenericExport } from "../hooks/useGenericExport";
import { openInErpnext } from "../lib/erpnextActions";
import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext";
import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi";
import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import { useToast } from "../components/ToastProvider"; import { useToast } from "../components/ToastProvider";
import type { InvoiceSummary, Settings } from "../lib/types"; import type { InvoiceSummary, Settings } from "../lib/types";
@@ -52,6 +58,8 @@ interface Props {
active: boolean; active: boolean;
/** Opens the detail page of an invoice. */ /** Opens the detail page of an invoice. */
onOpen: (id: number) => void; onOpen: (id: number) => void;
/** Goes to Settings, Integrations. */
onOpenSettings: () => void;
} }
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
@@ -65,7 +73,7 @@ const COLUMNS: { key: SortKey; label: string; numeric?: boolean }[] = [
{ key: "status", label: "Status" }, { key: "status", label: "Status" },
]; ];
export default function InvoiceHistory({ settings, active, onOpen }: Props) { export default function InvoiceHistory({ settings, active, onOpen, onOpenSettings }: Props) {
const toast = useToast(); const toast = useToast();
const [rows, setRows] = useState<InvoiceSummary[]>([]); const [rows, setRows] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -76,15 +84,36 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
const [pageSize, setPageSize] = useState(PAGE_SIZES[0]); const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
const exp = useInvoiceExport({ settings, bank: null }); const exp = useInvoiceExport({ settings, bank: null });
const [exportingHistory, setExportingHistory] = useState(false); const [exportingHistory, setExportingHistory] = useState(false);
const [erpCfg, setErpCfg] = useState<ErpnextConfig | null>(null);
const [syncs, setSyncs] = useState<Map<number, ErpnextSyncStatus>>(new Map());
const [selected, setSelected] = useState<Set<number>>(new Set());
const [sendTargets, setSendTargets] = useState<{ invoices: SendTarget[]; skipped: number } | null>(null);
const generic = useGenericExport();
/** One bulk call for every row's ERPNext status. ERPNext is optional, so a failure here only hides the column. */
const loadErpnext = async () => {
try {
const [cfg, list] = await Promise.all([api.erpnextGetConfig(), api.erpnextSyncStatuses()]);
setErpCfg(cfg);
setSyncs(indexSyncStatuses(list));
} catch {
setErpCfg(null);
setSyncs(new Map());
}
};
const load = async () => { const load = async () => {
try { try {
setRows(await api.listInvoices()); const list = await api.listInvoices();
setRows(list);
// Keep only selections that still exist.
setSelected((prev) => new Set([...prev].filter((id) => list.some((r) => r.id === id))));
} catch (e) { } catch (e) {
toast.error("Could not load invoices", String(e)); toast.error("Could not load invoices", String(e));
} finally { } finally {
setLoading(false); setLoading(false);
} }
void loadErpnext();
}; };
// The view stays mounted, so refetch every time it is shown again (a payment may have been recorded). // The view stays mounted, so refetch every time it is shown again (a payment may have been recorded).
@@ -113,6 +142,44 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
const onSort = (key: SortKey) => const onSort = (key: SortKey) =>
setSort((s) => (s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: key === "date" || key === "total" || key === "balance" ? "desc" : "asc" })); setSort((s) => (s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: key === "date" || key === "total" || key === "balance" ? "desc" : "asc" }));
const erpOn = isConfigured(erpCfg);
const vendorRegistered = settings.gstRegistration !== "unregistered";
const showErpColumn = erpOn || syncs.size > 0;
const selectedRows = rows.filter((r) => selected.has(r.id));
const blockedReason = pushDisabledReason({ cfg: erpCfg, vendorRegistered });
const pageAllSelected = shown.length > 0 && shown.every((r) => selected.has(r.id));
const toggleOne = (id: number, on: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
const togglePage = (on: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
for (const r of shown) {
if (on) next.add(r.id);
else next.delete(r.id);
}
return next;
});
const askSend = (list: InvoiceSummary[]) => {
const { send, skipped } = sendableSelection(list);
if (send.length === 0) {
toast.warning("Nothing to send", "Only issued invoices are sent to ERPNext.");
return;
}
setSendTargets({ invoices: send.map((r) => ({ id: r.id, number: r.number })), skipped });
};
const onOpenErpnext = async (id: number) => {
try {
await openInErpnext(id);
} catch (e) {
toast.error("Could not open ERPNext", String(e));
}
};
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => { const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
setBusyId(id); setBusyId(id);
try { try {
@@ -259,6 +326,38 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
</span> </span>
</div> </div>
<div className="voiced-history__bulk" role="group" aria-label="Actions for the selected invoices">
<span aria-live="polite">{selected.size} selected</span>
<Button kind="tertiary" size="md" disabled={selected.size === 0 || blockedReason !== null} onClick={() => askSend(selectedRows)}>
Send selected to ERPNext
</Button>
<Button kind="ghost" size="md" disabled={selected.size === 0 || generic.busy} onClick={() => void generic.run([...selected], "json")}>
Export selected (voiced.invoice.v1 JSON)
</Button>
<Button kind="ghost" size="md" disabled={selected.size === 0 || generic.busy} onClick={() => void generic.run([...selected], "csv")}>
Export selected (line items CSV)
</Button>
{selected.size > 0 ? (
<Button kind="ghost" size="md" onClick={() => setSelected(new Set())}>
Clear selection
</Button>
) : null}
{blockedReason !== null && selected.size > 0 ? (
<span className="voiced-muted">
{blockedReason}{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Open Settings
</Link>
</span>
) : null}
</div>
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<div className="voiced-empty">No invoices match these filters.</div> <div className="voiced-empty">No invoices match these filters.</div>
) : ( ) : (
@@ -266,6 +365,9 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
<Table aria-label="Invoices"> <Table aria-label="Invoices">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableHeader>
<Checkbox id="historySelectPage" labelText="Select all invoices on this page" hideLabel checked={pageAllSelected} onChange={(_e, { checked }) => togglePage(checked)} />
</TableHeader>
{COLUMNS.map((c) => ( {COLUMNS.map((c) => (
<TableHeader <TableHeader
key={c.key} key={c.key}
@@ -278,6 +380,7 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
{c.label} {c.label}
</TableHeader> </TableHeader>
))} ))}
{showErpColumn ? <TableHeader>ERPNext</TableHeader> : null}
<TableHeader> <TableHeader>
<span className="cds--assistive-text">Actions</span> <span className="cds--assistive-text">Actions</span>
</TableHeader> </TableHeader>
@@ -286,8 +389,20 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
<TableBody> <TableBody>
{shown.map((row) => { {shown.map((row) => {
const badge = statusBadge(row.status, row.paymentStatus); const badge = statusBadge(row.status, row.paymentStatus);
const sync = syncs.get(row.id);
const erpTag = syncTag(sync);
const rowBlocked = pushDisabledReason({ cfg: erpCfg, vendorRegistered, invoiceStatus: row.status });
return ( return (
<TableRow key={row.id} className="voiced-history__row" onClick={() => onOpen(row.id)}> <TableRow key={row.id} className="voiced-history__row" onClick={() => onOpen(row.id)}>
<TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Checkbox
id={`historySelect-${row.id}`}
labelText={`Select invoice ${row.number}`}
hideLabel
checked={selected.has(row.id)}
onChange={(_e, { checked }) => toggleOne(row.id, checked)}
/>
</TableCell>
<TableCell> <TableCell>
<Link <Link
href="#" href="#"
@@ -314,6 +429,13 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
</Tag> </Tag>
) : null} ) : null}
</TableCell> </TableCell>
{showErpColumn ? (
<TableCell>
<Tag type={erpTag.type} size="sm" title={erpTag.tooltip}>
{erpTag.label}
</Tag>
</TableCell>
) : null}
<TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}> <TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<OverflowMenu <OverflowMenu
aria-label={`Actions for invoice ${row.number}`} aria-label={`Actions for invoice ${row.number}`}
@@ -325,6 +447,17 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
<OverflowMenuItem itemText="Open" onClick={() => onOpen(row.id)} /> <OverflowMenuItem itemText="Open" onClick={() => onOpen(row.id)} />
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} /> <OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} /> <OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
<OverflowMenuItem itemText="Export as voiced.invoice.v1 (JSON)" hasDivider onClick={() => void generic.run([row.id], "json")} />
{erpOn || syncs.size > 0 ? (
<OverflowMenuItem
itemText="Send to ERPNext"
hasDivider
disabled={rowBlocked !== null}
title={rowBlocked ?? undefined}
onClick={() => askSend([row])}
/>
) : null}
{canOpenInErpnext(sync) && erpOn ? <OverflowMenuItem itemText="Open in ERPNext" onClick={() => void onOpenErpnext(row.id)} /> : null}
</OverflowMenu> </OverflowMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -363,6 +496,31 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
)} )}
</> </>
)} )}
{!erpOn && !loading && rows.length > 0 ? (
<p className="voiced-muted" style={{ marginTop: "1rem" }}>
ERPNext is not set up.{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Set it up in Settings, Integrations
</Link>{" "}
to send invoices to it.
</p>
) : null}
<SendToErpnextModal
open={sendTargets !== null}
invoices={sendTargets?.invoices ?? []}
skipped={sendTargets?.skipped ?? 0}
defaultSubmit={erpCfg?.submitOnPush ?? false}
onClose={() => setSendTargets(null)}
onFinished={() => void loadErpnext()}
/>
</div> </div>
); );
} }
+11
View File
@@ -59,6 +59,7 @@ import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import PageSetupControls from "../components/PageSetupControls"; import PageSetupControls from "../components/PageSetupControls";
import { useInvoicePdf } from "../hooks/useInvoicePdf"; import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport"; import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { autoPushIssued } from "../lib/erpnextUi";
import ExportButton, { ExportModePicker } from "../components/ExportButton"; import ExportButton, { ExportModePicker } from "../components/ExportButton";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import { createExportDeps } from "../lib/exportDeps"; import { createExportDeps } from "../lib/exportDeps";
@@ -649,6 +650,16 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
detachDraft(); detachDraft();
api.listClients().then(setClients).catch(() => {}); api.listClients().then(setClients).catch(() => {});
void refreshDrafts(); void refreshDrafts();
// "Send on issue": in the background, so it never delays or fails the issue; the result arrives as a toast.
void autoPushIssued(
{ getConfig: api.erpnextGetConfig, push: api.erpnextPushInvoice },
outcome.invoice,
settings.gstRegistration !== "unregistered",
)
.then((note) => {
if (note) toast[note.kind](note.title, note.subtitle);
})
.catch(() => {});
// Unarchived invoices export by rendering and archiving first (see exportInvoice), so this still works. // Unarchived invoices export by rendering and archiving first (see exportInvoice), so this still works.
await exp.start(outcome.invoice, mode, "original"); await exp.start(outcome.invoice, mode, "original");
setSaving(false); setSaving(false);