Add the ERPNext settings UI, History and detail controls, and voiced.invoice.v1 export (Phase F3)
- Settings gets an Integrations tab: connection fields with a write-only API secret, a CA PEM option, Test connection with versions and warnings, live mapping lists loaded after a test, naming mode, toggles and its own Save. - Pushing for a GST-registered vendor is refused until a connection test result is stored. The UI shows the reason and disables Send. - History shows an ERPNext status column from one bulk call, a row Send and Open in ERPNext, and a bulk Send selected with a draft-or-submit confirm step and per-row results that never abort the batch. The detail view gets an ERPNext panel and per-payment send. All of it is hidden or disabled when the integration is not configured. - Optional auto-push after issue runs in the background and never blocks or fails the issue. - Generic export: a deterministic voiced.invoice.v1 JSON (and CSV line items) for selected invoices, documented in docs/voiced-invoice-v1.md with a golden test. Money is integer paise plus a decimal string. The new screens have not been run in a webview yet. The ERPNext Payment Entry deduction fields still need a live check.
This commit is contained in:
@@ -0,0 +1,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.
|
||||||
@@ -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(|_| {});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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’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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: [],
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 & export</Tab>
|
<Tab>Templates & 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
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user