diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 2ced1e7..cbb977c 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,10 @@ ## [Non publié] +### Ajouté + +- Bilan : un compte détaillé (par titre) peut désormais **importer ses positions depuis un CSV** au lieu d'ajouter chaque titre un à un. Depuis un snapshot, un bouton « Importer un CSV » à côté de « Ajouter un titre » ouvre un sélecteur de fichier ; le séparateur, l'encodage et les colonnes (symbole, quantité et — au besoin — cours et coût d'acquisition) sont détectés automatiquement et ajustables avant l'import. Les lignes partageant un symbole sont fusionnées en une seule position, et un CSV sans colonne de cours importe tout de même les quantités pour récupérer ou saisir le prix ensuite (#245). + ### Sécurité - Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9442e91..f53aeba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Balance: a detailed (by-security) account can now **import its positions from a CSV** instead of adding each security one by one. From a snapshot, an "Import CSV" button next to "Add a title" opens a file picker; the delimiter, encoding, and columns (symbol, quantity, and — optionally — price and cost basis) are auto-detected and can be adjusted before importing. Rows that share a symbol are merged into one position, and a CSV without a price column still imports the quantities so the price can be fetched or typed afterwards (#245). + ### Security - Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238). diff --git a/src/components/balance/HoldingsCsvImportModal.tsx b/src/components/balance/HoldingsCsvImportModal.tsx new file mode 100644 index 0000000..4420191 --- /dev/null +++ b/src/components/balance/HoldingsCsvImportModal.tsx @@ -0,0 +1,351 @@ +// HoldingsCsvImportModal — bulk-import a detailed account's positions from a +// CSV instead of typing them one by one (Issue #245). +// +// Flow (all frontend — the save path is unchanged): +// 1. pick a CSV file (native dialog, reusing the existing `pick_import_file` +// Rust command with a CSV filter); +// 2. detect its encoding (`detect_encoding`, utf-8 fallback) + read it +// (`read_file_content`); +// 3. `analyzeHoldingsCsv` preprocesses, detects the delimiter/header and the +// symbol / quantity / unit_price / book_cost columns (flexible price +// detection: no price column ⇒ mapping stays null); +// 4. the user reviews/adjusts the mapping in a small editor + preview; +// 5. on confirm we hand the DATA rows + mapping up to the editor hook, which +// builds `HoldingDraft`s (FR numbers, duplicate-symbol merge) and merges +// them into the account's basket. The user then fetches/types any missing +// price and saves through the normal atomic path. +// +// The component owns the file/parse lifecycle; the actual draft building and +// basket merge live in `useSnapshotEditor` so they stay pure + unit-tested. + +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { invoke } from "@tauri-apps/api/core"; +import { Upload, X, AlertTriangle, FileSpreadsheet } from "lucide-react"; +import { + analyzeHoldingsCsv, + type HoldingColumnMapping, + type HoldingCsvAnalysis, +} from "../../utils/csvAutoDetect"; +import { normalizeSecuritySymbol } from "../../services/balance.service"; + +interface Props { + accountName: string; + /** Called with the parsed DATA rows + the confirmed column mapping. */ + onImport: (rows: string[][], mapping: HoldingColumnMapping) => void; + onClose: () => void; +} + +type Step = "select" | "loading" | "map" | "error"; + +const PREVIEW_ROWS = 5; + +export default function HoldingsCsvImportModal({ + accountName, + onImport, + onClose, +}: Props) { + const { t } = useTranslation(); + const [step, setStep] = useState("select"); + const [analysis, setAnalysis] = useState(null); + const [mapping, setMapping] = useState(null); + const [errorMsg, setErrorMsg] = useState(""); + + const pickAndAnalyze = useCallback(async () => { + setStep("loading"); + setErrorMsg(""); + try { + const filePath = await invoke("pick_import_file", { + filters: [["CSV", ["csv", "txt"]]], + }); + if (!filePath) { + // User cancelled the native dialog — return to the select step. + setStep("select"); + return; + } + + let encoding = "utf-8"; + try { + encoding = await invoke("detect_encoding", { + filePath, + }); + } catch { + // fall back to utf-8 + } + + const content = await invoke("read_file_content", { + filePath, + encoding, + }); + + const result = analyzeHoldingsCsv(content); + if (!result) { + setErrorMsg(t("balance.snapshot.detailed.importCsv.detectError")); + setStep("error"); + return; + } + setAnalysis(result); + setMapping(result.mapping); + setStep("map"); + } catch (e) { + setErrorMsg( + e instanceof Error + ? e.message + : t("balance.snapshot.detailed.importCsv.readError") + ); + setStep("error"); + } + }, [t]); + + const columnOptions = useMemo(() => { + if (!analysis) return null; + return analysis.headers.map((h, i) => ( + + )); + }, [analysis]); + + const preview = useMemo(() => { + if (!analysis || !mapping) return []; + return analysis.rows.slice(0, PREVIEW_ROWS).map((row) => { + const symbol = normalizeSecuritySymbol((row[mapping.symbol] ?? "").trim()); + const qty = (row[mapping.quantity] ?? "").trim(); + const price = + mapping.unit_price !== null + ? (row[mapping.unit_price] ?? "").trim() + : ""; + const book = + mapping.book_cost !== null ? (row[mapping.book_cost] ?? "").trim() : ""; + return { symbol, qty, price, book }; + }); + }, [analysis, mapping]); + + // Count the distinct, non-blank symbols that would be imported (after merge). + const importableCount = useMemo(() => { + if (!analysis || !mapping) return 0; + const seen = new Set(); + for (const row of analysis.rows) { + const s = normalizeSecuritySymbol((row[mapping.symbol] ?? "").trim()); + if (s) seen.add(s); + } + return seen.size; + }, [analysis, mapping]); + + const handleImport = () => { + if (!analysis || !mapping) return; + onImport(analysis.rows, mapping); + onClose(); + }; + + const selectClass = + "w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"; + + const setMappingField = ( + field: keyof HoldingColumnMapping, + raw: string + ) => { + if (!mapping) return; + const parsed = parseInt(raw, 10); + const next: number | null = + Number.isNaN(parsed) || parsed < 0 ? null : parsed; + setMapping({ ...mapping, [field]: next }); + }; + + return ( +
+
+
+
+ +

+ {t("balance.snapshot.detailed.importCsv.title")} +

+
+ +
+ +

+ {t("balance.snapshot.detailed.importCsv.intro", { + account: accountName, + })} +

+ + {(step === "select" || step === "loading") && ( +
+ +

+ {t("balance.snapshot.detailed.importCsv.hint")} +

+
+ )} + + {step === "error" && ( +
+
+ + {errorMsg} +
+
+ +
+
+ )} + + {step === "map" && analysis && mapping && ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {mapping.unit_price === null && ( +

+ {t("balance.snapshot.detailed.importCsv.noPriceNote")} +

+ )} + + {/* Preview of the first rows as they will be imported. */} +
+ + + + + + + + + + + {preview.map((r, i) => ( + + + + + + + ))} + +
+ {t("balance.snapshot.detailed.col.title")} + + {t("balance.snapshot.detailed.col.quantity")} + + {t("balance.snapshot.detailed.col.unitPrice")} + + {t("balance.snapshot.detailed.col.bookCost")} +
+ {r.symbol || "—"} + {r.qty || "—"} + {r.price || "—"} + {r.book || "—"}
+
+ +

+ {t("balance.snapshot.detailed.importCsv.summary", { + count: importableCount, + })} +

+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/components/balance/SnapshotEditor.tsx b/src/components/balance/SnapshotEditor.tsx index d07c8b5..add66ac 100644 --- a/src/components/balance/SnapshotEditor.tsx +++ b/src/components/balance/SnapshotEditor.tsx @@ -14,6 +14,7 @@ import type { BalanceSecurity, } from "../../shared/types"; import type { HoldingDraft } from "../../hooks/useSnapshotEditor"; +import type { HoldingColumnMapping } from "../../utils/csvAutoDetect"; import type { SecurityPick } from "./SecurityPicker"; import SnapshotLineRow from "./SnapshotLineRow"; import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel"; @@ -42,6 +43,12 @@ interface Props { rowId: string, pick: SecurityPick ) => void; + /** Import a batch of holdings from a CSV into a detailed account (#245). */ + onImportHoldings: ( + accountId: number, + rows: string[][], + mapping: HoldingColumnMapping + ) => void; disabled?: boolean; /** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */ snapshotDate?: string; @@ -58,6 +65,7 @@ export default function SnapshotEditor({ onRemoveHolding, onHoldingFieldChange, onHoldingSecurityPick, + onImportHoldings, disabled, snapshotDate, }: Props) { @@ -124,6 +132,9 @@ export default function SnapshotEditor({ onHoldingSecurityPick={(rowId, pick) => onHoldingSecurityPick(acc.id, rowId, pick) } + onImportHoldings={(rows, mapping) => + onImportHoldings(acc.id, rows, mapping) + } disabled={disabled} snapshotDate={snapshotDate} /> diff --git a/src/components/balance/SnapshotLineRow.tsx b/src/components/balance/SnapshotLineRow.tsx index 9a59c19..a40815b 100644 --- a/src/components/balance/SnapshotLineRow.tsx +++ b/src/components/balance/SnapshotLineRow.tsx @@ -25,16 +25,18 @@ // strings on every change. Numeric validation happens at save time in // `useSnapshotEditor.save`. -import { ChangeEvent, useMemo } from "react"; +import { ChangeEvent, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus, Trash2, Upload } from "lucide-react"; import type { BalanceAccountWithCategory, BalanceSecurity, } from "../../shared/types"; import type { HoldingDraft } from "../../hooks/useSnapshotEditor"; +import type { HoldingColumnMapping } from "../../utils/csvAutoDetect"; import PriceFetchControl from "./PriceFetchControl"; import SecurityPicker, { type SecurityPick } from "./SecurityPicker"; +import HoldingsCsvImportModal from "./HoldingsCsvImportModal"; interface Props { account: BalanceAccountWithCategory; @@ -57,6 +59,8 @@ interface Props { ) => void; /** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */ onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void; + /** Import a batch of holdings from a CSV into this detailed account (#245). */ + onImportHoldings?: (rows: string[][], mapping: HoldingColumnMapping) => void; } /** @@ -84,9 +88,11 @@ export default function SnapshotLineRow({ onRemoveHolding, onHoldingFieldChange, onHoldingSecurityPick, + onImportHoldings, }: Props) { const { t } = useTranslation(); const isDetailed = account.kind === "detailed"; + const [importOpen, setImportOpen] = useState(false); // Account total across the basket (live as the user types). const detailedTotal = useMemo(() => { @@ -152,15 +158,36 @@ export default function SnapshotLineRow({ )} - +
+ + {onImportHoldings && ( + + )} +
+ + {importOpen && onImportHoldings && ( + setImportOpen(false)} + /> + )} ); } diff --git a/src/hooks/useSnapshotEditor.test.ts b/src/hooks/useSnapshotEditor.test.ts index acb4f5b..b0cb067 100644 --- a/src/hooks/useSnapshotEditor.test.ts +++ b/src/hooks/useSnapshotEditor.test.ts @@ -28,10 +28,12 @@ import { initialState, makeEmptyHolding, holdingsFromServiceHoldings, + holdingsFromCsvRows, buildSimpleLines, buildDetailedLines, type HoldingDraft, } from "./useSnapshotEditor"; +import type { HoldingColumnMapping } from "../utils/csvAutoDetect"; import { BalanceServiceError } from "../services/balance.service"; import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types"; @@ -291,6 +293,222 @@ describe("buildDetailedLines — holdings save path (#213)", () => { }); }); +describe("holdingsFromCsvRows — CSV rows → drafts (#245)", () => { + const withPrice: HoldingColumnMapping = { + symbol: 0, + quantity: 1, + unit_price: 2, + book_cost: 3, + }; + + it("maps rows to drafts, normalizing the symbol and parsing FR numbers", () => { + const drafts = holdingsFromCsvRows( + [ + ["aapl", "10", "150,25", "1 200,00"], + ["msft", "5", "300.50", "1400"], + ], + withPrice + ); + expect(drafts).toHaveLength(2); + expect(drafts[0].symbol).toBe("AAPL"); // normalized UPPER/TRIM + expect(drafts[0].quantity).toBe("10"); + expect(drafts[0].unit_price).toBe("150.25"); // FR comma decimal + expect(drafts[0].book_cost).toBe("1200"); // FR thousands space stripped + expect(drafts[1].symbol).toBe("MSFT"); + expect(drafts[1].unit_price).toBe("300.5"); + }); + + it("leaves unit_price empty when there is no price column (flexible price)", () => { + const noPrice: HoldingColumnMapping = { + symbol: 0, + quantity: 1, + unit_price: null, + book_cost: null, + }; + const [d] = holdingsFromCsvRows([["AAPL", "10"]], noPrice); + expect(d.symbol).toBe("AAPL"); + expect(d.quantity).toBe("10"); + expect(d.unit_price).toBe(""); // → validated at save (fetch/type later) + expect(d.book_cost).toBe(""); + }); + + it("merges duplicate symbols: SUM quantity + book_cost, keep first price", () => { + const drafts = holdingsFromCsvRows( + [ + ["AAPL", "6", "150.00", "700"], + ["MSFT", "5", "300.00", "1400"], + ["AAPL", "4", "151.00", "500"], // second AAPL lot + ], + withPrice + ); + // Two distinct securities → two drafts, no UNIQUE(symbol) violation. + expect(drafts).toHaveLength(2); + const aapl = drafts.find((d) => d.symbol === "AAPL")!; + expect(aapl.quantity).toBe("10"); // 6 + 4 + expect(aapl.book_cost).toBe("1200"); // 700 + 500 + expect(aapl.unit_price).toBe("150"); // FIRST non-empty price wins + }); + + it("skips rows with a blank symbol and defaults asset_type", () => { + const drafts = holdingsFromCsvRows( + [ + ["", "10", "1", "1"], + [" ", "10", "1", "1"], + ["BTC", "0.5", "1000", "400"], + ], + withPrice, + { defaultAssetType: "crypto" } + ); + expect(drafts).toHaveLength(1); + expect(drafts[0].symbol).toBe("BTC"); + expect(drafts[0].asset_type).toBe("crypto"); + }); +}); + +describe("IMPORT_HOLDINGS reducer — bulk merge into a basket (#245)", () => { + const mapping: HoldingColumnMapping = { + symbol: 0, + quantity: 1, + unit_price: 2, + book_cost: 3, + }; + + it("appends imported drafts into an empty basket", () => { + const drafts = holdingsFromCsvRows( + [ + ["AAPL", "10", "150", "1200"], + ["MSFT", "5", "300", "1400"], + ], + mapping + ); + const s = reducer(base, { + type: "IMPORT_HOLDINGS", + payload: { accountId: 5, holdings: drafts }, + }); + expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "MSFT"]); + expect(s.isDirty).toBe(true); + }); + + it("updates an existing symbol in place (keeps its rowId) and appends new ones", () => { + const first = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping); + let s = reducer(base, { + type: "IMPORT_HOLDINGS", + payload: { accountId: 5, holdings: first }, + }); + const aaplRowId = s.holdings[5][0].rowId; + + const second = holdingsFromCsvRows( + [ + ["AAPL", "12", "160", "1300"], + ["GOOG", "2", "140", "260"], + ], + mapping + ); + s = reducer(s, { + type: "IMPORT_HOLDINGS", + payload: { accountId: 5, holdings: second }, + }); + // No duplicate AAPL row — merged in place with a stable rowId. + expect(s.holdings[5].filter((h) => h.symbol === "AAPL")).toHaveLength(1); + const aapl = s.holdings[5].find((h) => h.symbol === "AAPL")!; + expect(aapl.rowId).toBe(aaplRowId); + expect(aapl.quantity).toBe("12"); // import wins for quantity + expect(aapl.unit_price).toBe("160"); + expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "GOOG"]); + }); + + it("an import WITHOUT price does not wipe a previously-set price", () => { + const priced = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping); + let s = reducer(base, { + type: "IMPORT_HOLDINGS", + payload: { accountId: 5, holdings: priced }, + }); + const noPriceMapping: HoldingColumnMapping = { + symbol: 0, + quantity: 1, + unit_price: null, + book_cost: null, + }; + const repriced = holdingsFromCsvRows([["AAPL", "20"]], noPriceMapping); + s = reducer(s, { + type: "IMPORT_HOLDINGS", + payload: { accountId: 5, holdings: repriced }, + }); + const aapl = s.holdings[5][0]; + expect(aapl.quantity).toBe("20"); // qty updated + expect(aapl.unit_price).toBe("150"); // price preserved + }); + + it("produces no duplicate symbols when the batch itself repeats a symbol", () => { + // holdingsFromCsvRows already merges within the batch; the reducer is a + // second guard. Even if fed pre-merged drafts, the basket stays unique. + const drafts = holdingsFromCsvRows( + [ + ["AAPL", "3", "150", "450"], + ["AAPL", "7", "150", "1050"], + ], + mapping + ); + const s = reducer(base, { + type: "IMPORT_HOLDINGS", + payload: { accountId: 5, holdings: drafts }, + }); + expect(s.holdings[5]).toHaveLength(1); + expect(s.holdings[5][0].quantity).toBe("10"); + }); +}); + +describe("IMPORT_HOLDINGS → buildDetailedLines end-to-end (#245)", () => { + const mapping: HoldingColumnMapping = { + symbol: 0, + quantity: 1, + unit_price: 2, + book_cost: 3, + }; + + it("imported priced drafts flow through buildDetailedLines to holdings", () => { + const drafts = holdingsFromCsvRows( + [ + ["AAPL", "10", "150.25", "1200"], + ["MSFT", "5", "300.50", "1400"], + ], + mapping + ); + const s = reducer(base, { + type: "IMPORT_HOLDINGS", + payload: { accountId: 5, holdings: drafts }, + }); + const [line] = buildDetailedLines(s.holdings, new Set([5])); + expect(line.holdings).toHaveLength(2); + expect(line.holdings![0].symbol).toBe("AAPL"); + expect(line.holdings![0].unit_price).toBe(150.25); + expect(line.holdings![0].book_cost).toBe(1200); + // 10 * 150.25 + 5 * 300.50 = 1502.50 + 1502.50 = 3005. + expect(line.value).toBe(3005); + }); + + it("imported drafts WITHOUT price fail save validation until fetched (GOTCHA)", () => { + const noPriceMapping: HoldingColumnMapping = { + symbol: 0, + quantity: 1, + unit_price: null, + book_cost: null, + }; + const drafts = holdingsFromCsvRows([["AAPL", "10"]], noPriceMapping); + const s = reducer(base, { + type: "IMPORT_HOLDINGS", + payload: { accountId: 5, holdings: drafts }, + }); + let code: string | null = null; + try { + buildDetailedLines(s.holdings, new Set([5])); + } catch (e) { + code = (e as BalanceServiceError).code; + } + expect(code).toBe("snapshot_priced_unit_price_required"); + }); +}); + describe("dispatch on account.kind — detailed under a 'simple' category (#213)", () => { it("routes a detailed account through holdings even if its category is simple", () => { // Regression target: the account's OWN kind decides the path. Account 5 is diff --git a/src/hooks/useSnapshotEditor.ts b/src/hooks/useSnapshotEditor.ts index bebbd53..67cb5e6 100644 --- a/src/hooks/useSnapshotEditor.ts +++ b/src/hooks/useSnapshotEditor.ts @@ -52,10 +52,13 @@ import { getPreviousSnapshot, getHoldingsForLatestSnapshot, listHoldingsBySnapshotLine, + normalizeSecuritySymbol, BalanceServiceError, type SnapshotLineInput, type SnapshotHoldingInput, } from "../services/balance.service"; +import { parseFrenchAmount } from "../utils/amountParser"; +import type { HoldingColumnMapping } from "../utils/csvAutoDetect"; export type SnapshotEditorMode = "new" | "edit"; @@ -152,6 +155,77 @@ export function holdingsFromServiceHoldings( })); } +/** + * Map parsed CSV data rows into holding drafts for a detailed account (Issue + * #245 — CSV import). `rows` are DATA rows only (no header); `mapping` gives the + * column indices from `analyzeHoldingsCsv`. Behavior: + * - Symbols are normalized (UPPER/TRIM) like manual entry (SecurityPicker) so + * an imported title collapses onto the same `balance_securities` row. + * - Numbers are parsed with `parseFrenchAmount` (handles `1 234,56`, `1,234.56`). + * - unit_price + book_cost are OPTIONAL: when the mapping's column is null (no + * price/cost column) the field stays empty; the user fetches/types it later. + * - Duplicate symbols WITHIN the CSV are merged into one draft to respect the + * UNIQUE(snapshot_line_id, security_id) constraint: quantities and book_costs + * are SUMMED (multiple lots of the same title) and the FIRST non-empty price + * is kept (the current market price is shared across lots). + * Rows with a blank symbol are skipped. Exported for unit tests. + */ +export function holdingsFromCsvRows( + rows: string[][], + mapping: HoldingColumnMapping, + opts: { defaultAssetType?: BalanceAssetType } = {} +): HoldingDraft[] { + const defaultAssetType = opts.defaultAssetType ?? "stock"; + const order: string[] = []; + const bySymbol = new Map< + string, + { symbol: string; qty: number; book: number | null; price: string } + >(); + + for (const row of rows) { + const rawSymbol = (row[mapping.symbol] ?? "").trim(); + if (!rawSymbol) continue; + const symbol = normalizeSecuritySymbol(rawSymbol); + if (!symbol) continue; + + const qtyParsed = parseFrenchAmount((row[mapping.quantity] ?? "").trim()); + const qty = isNaN(qtyParsed) ? 0 : qtyParsed; + + let price = ""; + if (mapping.unit_price !== null) { + const p = parseFrenchAmount((row[mapping.unit_price] ?? "").trim()); + if (!isNaN(p)) price = String(p); + } + + let book: number | null = null; + if (mapping.book_cost !== null) { + const b = parseFrenchAmount((row[mapping.book_cost] ?? "").trim()); + if (!isNaN(b)) book = b; + } + + const existing = bySymbol.get(symbol); + if (existing) { + existing.qty += qty; + if (book !== null) existing.book = (existing.book ?? 0) + book; + if (!existing.price && price) existing.price = price; // first non-empty + } else { + order.push(symbol); + bySymbol.set(symbol, { symbol, qty, book, price }); + } + } + + return order.map((sym) => { + const a = bySymbol.get(sym)!; + return { + ...makeEmptyHolding(defaultAssetType), + symbol: a.symbol, + quantity: String(a.qty), + unit_price: a.price, + book_cost: a.book !== null ? String(a.book) : "", + }; + }); +} + interface State { mode: SnapshotEditorMode; /** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */ @@ -212,6 +286,14 @@ type Action = type: "ADD_HOLDING"; payload: { accountId: number; holding: HoldingDraft }; } + | { + // Bulk-merge a batch of imported drafts into a detailed account's basket + // (Issue #245 — CSV import). Merges by normalized symbol so the batch + // never introduces a duplicate that would violate + // UNIQUE(snapshot_line_id, security_id) at save. + type: "IMPORT_HOLDINGS"; + payload: { accountId: number; holdings: HoldingDraft[] }; + } | { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } } | { type: "SET_HOLDING_FIELD"; @@ -325,6 +407,56 @@ export function reducer(state: State, action: Action): State { isDirty: true, }; } + case "IMPORT_HOLDINGS": { + // Merge the imported batch into the existing basket by NORMALIZED symbol. + // A symbol already present is UPDATED in place (keeping its rowId for a + // stable React key); a new symbol is appended. This guarantees the basket + // never holds two rows for the same security (UNIQUE constraint at save). + const existing = state.holdings[action.payload.accountId] ?? []; + const result = existing.slice(); + const indexBySymbol = new Map(); + result.forEach((h, i) => { + const key = normalizeSecuritySymbol(h.symbol); + if (key) indexBySymbol.set(key, i); + }); + for (const draft of action.payload.holdings) { + const key = normalizeSecuritySymbol(draft.symbol); + if (!key) continue; // imported rows always carry a symbol; guard anyway + const idx = indexBySymbol.get(key); + if (idx !== undefined) { + const prev = result[idx]; + result[idx] = { + ...prev, + symbol: draft.symbol, + // Keep the existing asset_type: the CSV carries none (the draft only + // has the account's default class), while the current row's class + // was set deliberately via the picker or a prior entry. + security_name: draft.security_name || prev.security_name, + quantity: draft.quantity, + // Import wins for price/book_cost ONLY when it carries a value, so an + // import-without-price never wipes a previously fetched/typed price. + unit_price: draft.unit_price !== "" ? draft.unit_price : prev.unit_price, + book_cost: draft.book_cost !== "" ? draft.book_cost : prev.book_cost, + // A fresh imported price is a manual value → drop stale attribution. + price_source: + draft.unit_price !== "" ? null : prev.price_source, + price_fetched_at: + draft.unit_price !== "" ? null : prev.price_fetched_at, + }; + } else { + indexBySymbol.set(key, result.length); + result.push(draft); + } + } + return { + ...state, + holdings: { + ...state.holdings, + [action.payload.accountId]: result, + }, + isDirty: true, + }; + } case "REMOVE_HOLDING": { const existing = state.holdings[action.payload.accountId] ?? []; return { @@ -665,6 +797,35 @@ export function useSnapshotEditor(options: Options = {}) { [] ); + /** + * Import a batch of holdings from parsed CSV rows into a detailed account + * (Issue #245). Builds drafts via `holdingsFromCsvRows` (FR numbers, optional + * price, duplicate-symbol merge) then dispatches IMPORT_HOLDINGS, which merges + * them into the account's basket by symbol. New securities default to the + * account's category asset class (else 'stock'). + */ + const importHoldings = useCallback( + (accountId: number, rows: string[][], mapping: HoldingColumnMapping) => { + const acc = state.accounts.find((a) => a.id === accountId); + const drafts = holdingsFromCsvRows(rows, mapping, { + defaultAssetType: acc?.category_asset_type ?? "stock", + }); + if (drafts.length === 0) { + logInfo("Balance: CSV import produced no holdings (no valid rows)"); + return 0; + } + dispatch({ + type: "IMPORT_HOLDINGS", + payload: { accountId, holdings: drafts }, + }); + logInfo( + `Balance: imported ${drafts.length} holding(s) from CSV into account ${accountId}` + ); + return drafts.length; + }, + [state.accounts] + ); + const removeHolding = useCallback((accountId: number, rowId: string) => { dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } }); }, []); @@ -843,6 +1004,7 @@ export function useSnapshotEditor(options: Options = {}) { setDate, setLineValue, addHolding, + importHoldings, removeHolding, setHoldingField, setHoldingSecurity, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2610644..9c2b408 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1795,6 +1795,25 @@ "stock": "Stock", "crypto": "Crypto" } + }, + "importCsv": { + "button": "Import CSV", + "title": "Import securities from a CSV", + "intro": "Import the positions of \"{{account}}\" from a CSV file (broker statement, export). Columns are detected automatically; adjust them if needed.", + "selectFile": "Choose a CSV file", + "loading": "Reading file…", + "hint": "Accepted formats: .csv, .txt. Delimiter and encoding are detected automatically.", + "detectError": "Could not detect columns in this file. Make sure it is a valid securities CSV.", + "readError": "Could not read the file.", + "retry": "Retry", + "symbolColumn": "Symbol column", + "quantityColumn": "Quantity column", + "priceColumn": "Price column (optional)", + "bookCostColumn": "Book cost column (optional)", + "noColumn": "— None —", + "noPriceNote": "No price column: securities will be imported without a price. You can fetch or type it afterwards.", + "summary": "{{count}} security(ies) will be imported.", + "import": "Import" } }, "delete": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5e8d2a2..48d5a11 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1795,6 +1795,25 @@ "stock": "Action", "crypto": "Crypto" } + }, + "importCsv": { + "button": "Importer un CSV", + "title": "Importer des titres depuis un CSV", + "intro": "Importez les positions de « {{account}} » depuis un fichier CSV (relevé de courtier, exportation). Les colonnes sont détectées automatiquement ; ajustez-les au besoin.", + "selectFile": "Choisir un fichier CSV", + "loading": "Lecture du fichier…", + "hint": "Formats acceptés : .csv, .txt. Le séparateur et l'encodage sont détectés automatiquement.", + "detectError": "Impossible de détecter les colonnes dans ce fichier. Vérifiez qu'il s'agit d'un CSV de titres valide.", + "readError": "Impossible de lire le fichier.", + "retry": "Réessayer", + "symbolColumn": "Colonne du symbole", + "quantityColumn": "Colonne de la quantité", + "priceColumn": "Colonne du cours (facultatif)", + "bookCostColumn": "Colonne du coût d'acquisition (facultatif)", + "noColumn": "— Aucune —", + "noPriceNote": "Aucune colonne de cours : les titres seront importés sans prix. Vous pourrez le récupérer ou le saisir ensuite.", + "summary": "{{count}} titre(s) seront importé(s).", + "import": "Importer" } }, "delete": { diff --git a/src/pages/SnapshotEditPage.tsx b/src/pages/SnapshotEditPage.tsx index 4293c6a..24a639e 100644 --- a/src/pages/SnapshotEditPage.tsx +++ b/src/pages/SnapshotEditPage.tsx @@ -212,6 +212,7 @@ export default function SnapshotEditPage() { onRemoveHolding={editor.removeHolding} onHoldingFieldChange={editor.setHoldingField} onHoldingSecurityPick={editor.setHoldingSecurity} + onImportHoldings={editor.importHoldings} disabled={state.isSaving} snapshotDate={state.snapshotDate} /> diff --git a/src/utils/csvAutoDetect.test.ts b/src/utils/csvAutoDetect.test.ts new file mode 100644 index 0000000..42260a5 --- /dev/null +++ b/src/utils/csvAutoDetect.test.ts @@ -0,0 +1,123 @@ +// csvAutoDetect — holdings-CSV detection tests (Issue #245). +// +// Covers `autoDetectHoldingColumns` (column detection) and `analyzeHoldingsCsv` +// (delimiter/header + full analysis). These helpers are pure and DOM-free, in +// line with the project's "test the extracted pure pieces" convention. The +// numeric parsing + duplicate-merge path lives in `holdingsFromCsvRows` +// (tested in useSnapshotEditor.test.ts). + +import { describe, it, expect } from "vitest"; +import { + autoDetectHoldingColumns, + analyzeHoldingsCsv, +} from "./csvAutoDetect"; + +describe("autoDetectHoldingColumns (#245)", () => { + it("detects symbol/quantity/price/book_cost from EN headers", () => { + const data = [ + ["Symbol", "Quantity", "Price", "Book Cost"], + ["AAPL", "10", "150.25", "1200.00"], + ["MSFT", "5", "300.50", "1400.00"], + ]; + expect(autoDetectHoldingColumns(data, true)).toEqual({ + symbol: 0, + quantity: 1, + unit_price: 2, + book_cost: 3, + }); + }); + + it("leaves unit_price + book_cost null when the CSV has no such columns", () => { + const data = [ + ["Symbol", "Shares"], + ["AAPL", "10"], + ["MSFT", "5"], + ]; + const m = autoDetectHoldingColumns(data, true)!; + expect(m.symbol).toBe(0); + expect(m.quantity).toBe(1); + expect(m.unit_price).toBeNull(); + expect(m.book_cost).toBeNull(); + }); + + it("never maps a Value (qty×price) column to price or book_cost", () => { + const data = [ + ["Symbol", "Quantity", "Price", "Value"], + ["AAPL", "10", "150.25", "1502.50"], + ["MSFT", "4", "300.50", "1202.00"], + ]; + const m = autoDetectHoldingColumns(data, true)!; + expect(m.symbol).toBe(0); + expect(m.quantity).toBe(1); + expect(m.unit_price).toBe(2); + // The Value column (col 3) must NOT be picked up as the cost basis. + expect(m.book_cost).toBeNull(); + }); + + it("detects headerless CSVs positionally (symbol / int qty / decimal price)", () => { + const data = [ + ["AAPL", "10", "150.25"], + ["MSFT", "5", "300.50"], + ["GOOG", "2", "140.10"], + ]; + const m = autoDetectHoldingColumns(data, false)!; + expect(m.symbol).toBe(0); + expect(m.quantity).toBe(1); + expect(m.unit_price).toBe(2); + }); + + it("returns null when there are no data rows", () => { + expect(autoDetectHoldingColumns([], true)).toBeNull(); + expect(autoDetectHoldingColumns([["Symbol", "Qty"]], true)).toBeNull(); + }); +}); + +describe("analyzeHoldingsCsv (#245)", () => { + it("parses an EN comma CSV with a header row", () => { + const csv = "Symbol,Quantity,Price\nAAPL,10,150.25\nMSFT,5,300.50\n"; + const a = analyzeHoldingsCsv(csv)!; + expect(a.delimiter).toBe(","); + expect(a.hasHeader).toBe(true); + expect(a.headers).toEqual(["Symbol", "Quantity", "Price"]); + expect(a.rows).toEqual([ + ["AAPL", "10", "150.25"], + ["MSFT", "5", "300.50"], + ]); + expect(a.mapping).toEqual({ + symbol: 0, + quantity: 1, + unit_price: 2, + book_cost: null, + }); + }); + + it("handles FR headers (accents), a semicolon delimiter and FR numbers", () => { + const csv = + "Symbole;Quantité;Cours;Coût\nAAPL;10;150,25;1 200,00\nMSFT;5;300,50;1 400,00\n"; + const a = analyzeHoldingsCsv(csv)!; + expect(a.delimiter).toBe(";"); + expect(a.hasHeader).toBe(true); + expect(a.mapping).toEqual({ + symbol: 0, + quantity: 1, + unit_price: 2, + book_cost: 3, + }); + expect(a.rows[0]).toEqual(["AAPL", "10", "150,25", "1 200,00"]); + }); + + it("returns generated headers + all rows for a headerless CSV", () => { + const csv = "AAPL,10,150.25\nMSFT,5,300.50\nGOOG,2,140.10\n"; + const a = analyzeHoldingsCsv(csv)!; + expect(a.hasHeader).toBe(false); + expect(a.headers).toEqual(["Col 0", "Col 1", "Col 2"]); + expect(a.rows).toHaveLength(3); + expect(a.mapping.symbol).toBe(0); + expect(a.mapping.quantity).toBe(1); + }); + + it("returns null on empty / whitespace-only content", () => { + expect(analyzeHoldingsCsv("")).toBeNull(); + expect(analyzeHoldingsCsv(" \n \n")).toBeNull(); + }); +}); diff --git a/src/utils/csvAutoDetect.ts b/src/utils/csvAutoDetect.ts index 514e868..e6bd16a 100644 --- a/src/utils/csvAutoDetect.ts +++ b/src/utils/csvAutoDetect.ts @@ -556,3 +556,301 @@ function isSparseComplementary( return total > 0 && complementary / total >= 0.7; } + +// ----------------------------------------------------------------------------- +// Holdings CSV detection (Issue #245) — a detailed balance account can import a +// CSV of positions instead of typing them one by one. This is a SEPARATE flow +// from the transaction import above: `autoDetectConfig` is coupled to the +// date/amount transaction model, whereas a holdings CSV maps to +// symbol / quantity / unit_price / book_cost. Price + book_cost are OPTIONAL +// (flexible price detection): when the CSV has no price column the mapping +// leaves it `null` and the holdings import without a price (the user fetches or +// types it afterwards). Detection is best-effort and the UI lets the user +// adjust every column, so an imperfect guess is always recoverable. +// ----------------------------------------------------------------------------- + +export interface HoldingColumnMapping { + /** Column index of the security symbol/ticker (required). */ + symbol: number; + /** Column index of the quantity held (required). */ + quantity: number; + /** Column index of the unit price, or null when the CSV has no price column. */ + unit_price: number | null; + /** Column index of the acquisition cost basis, or null when absent. */ + book_cost: number | null; +} + +export interface HoldingCsvAnalysis { + delimiter: string; + hasHeader: boolean; + /** Header labels (actual cells when `hasHeader`, else `Col 0`, `Col 1`, …). */ + headers: string[]; + /** DATA rows only (the header row, if any, is stripped). */ + rows: string[][]; + mapping: HoldingColumnMapping; +} + +// Header keyword sets, matched against accent-stripped, alphanumeric-only +// header cells. Bilingual (FR default + EN). Order inside each list is priority +// order for the primary-keyword pass. +const SYMBOL_HEADER_KEYWORDS = ["symbol", "symbole", "ticker", "titre"]; +const QUANTITY_HEADER_KEYWORDS = [ + "quantity", + "quantite", + "qty", + "qte", + "shares", + "actions", + "parts", + "unites", + "units", + "nombre", +]; +const PRICE_HEADER_KEYWORDS = [ + "prixunitaire", + "unitprice", + "marketprice", + "price", + "prix", + "cours", + "cotation", + "cloture", + "close", +]; +const BOOKCOST_HEADER_KEYWORDS = [ + "bookcost", + "costbasis", + "prixderevient", + "acquisition", + "revient", + "cost", + "cout", +]; +// Value/market-value columns must never be auto-picked as price or book_cost. +const VALUE_HEADER_KEYWORDS = ["value", "valeur", "montant", "marchande"]; + +/** Accent-strip + lowercase + keep alphanumerics only (for header matching). */ +function normalizeHeaderCell(s: string): string { + return (s ?? "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); +} + +/** + * Find the first unused column whose normalized header contains one of the + * keywords (keywords tried in priority order). `exclude` skips columns whose + * header contains any excluded token (e.g. a "value" column for price). + */ +function matchHeaderColumn( + normalizedHeaders: string[], + keywords: string[], + used: Set, + exclude: string[] = [] +): number | null { + for (const kw of keywords) { + for (let i = 0; i < normalizedHeaders.length; i++) { + if (used.has(i)) continue; + const h = normalizedHeaders[i]; + if (!h) continue; + if (exclude.some((ex) => h.includes(ex))) continue; + if (h.includes(kw)) return i; + } + } + return null; +} + +/** Pick the shortest-average-length non-numeric, unused text column (symbols + * are short tokens; a name/description column is longer). */ +function pickSymbolColumn( + rows: string[][], + colCount: number, + numericCols: Set, + used: Set +): number | null { + let best: number | null = null; + let bestAvg = Infinity; + for (let col = 0; col < colCount; col++) { + if (used.has(col) || numericCols.has(col)) continue; + let totalLen = 0; + let count = 0; + for (const row of rows) { + const cell = row[col]?.trim(); + if (!cell) continue; + totalLen += cell.length; + count++; + } + if (count === 0) continue; + const avg = totalLen / count; + if (avg < bestAvg) { + bestAvg = avg; + best = col; + } + } + return best; +} + +/** Pick an unused numeric column, optionally preferring integer-heavy (quantity) + * or decimal-heavy (price) columns. Returns null when none remain. */ +function pickNumericColumn( + rows: string[][], + numericCols: number[], + used: Set, + opts: { preferIntegers?: boolean; preferDecimals?: boolean } +): number | null { + let best: number | null = null; + let bestScore = -1; + for (const col of numericCols) { + if (used.has(col)) continue; + let ints = 0; + let decimals = 0; + let nonEmpty = 0; + for (const row of rows) { + const cell = row[col]?.trim(); + if (!cell) continue; + const v = parseFrenchAmount(cell); + if (isNaN(v)) continue; + nonEmpty++; + if (Number.isInteger(v)) ints++; + else decimals++; + } + if (nonEmpty === 0) continue; + let score = 1; + if (opts.preferIntegers) score = ints / nonEmpty; + else if (opts.preferDecimals) score = decimals / nonEmpty; + if (score > bestScore) { + bestScore = score; + best = col; + } + } + return best; +} + +/** + * Detect the symbol / quantity / unit_price / book_cost columns of a holdings + * CSV. `data` is the parsed 2-D array INCLUDING the header row when + * `hasHeader`. Returns a best-effort mapping (symbol + quantity always set so + * the editor can render), or null only when there is no usable data. + * Exported for unit tests. + */ +export function autoDetectHoldingColumns( + data: string[][], + hasHeader: boolean +): HoldingColumnMapping | null { + if (data.length === 0) return null; + const colCount = Math.max(...data.map((r) => r.length)); + if (colCount === 0) return null; + const dataRows = hasHeader ? data.slice(1) : data; + if (dataRows.length === 0) return null; + + const used = new Set(); + let symbol: number | null = null; + let quantity: number | null = null; + let unitPrice: number | null = null; + let bookCost: number | null = null; + + // Step 1 — header keyword matching (most reliable when present). + if (hasHeader) { + const normalized = data[0].map(normalizeHeaderCell); + symbol = matchHeaderColumn(normalized, SYMBOL_HEADER_KEYWORDS, used); + if (symbol !== null) used.add(symbol); + quantity = matchHeaderColumn(normalized, QUANTITY_HEADER_KEYWORDS, used); + if (quantity !== null) used.add(quantity); + unitPrice = matchHeaderColumn( + normalized, + PRICE_HEADER_KEYWORDS, + used, + VALUE_HEADER_KEYWORDS + ); + if (unitPrice !== null) used.add(unitPrice); + bookCost = matchHeaderColumn( + normalized, + BOOKCOST_HEADER_KEYWORDS, + used, + VALUE_HEADER_KEYWORDS + ); + if (bookCost !== null) used.add(bookCost); + } + + // A value / market-value column must never be auto-assigned to a numeric + // target by the heuristics below (it is qty × price, not a source column). + // Mark such columns used up-front so the fallback pickers skip them. + if (hasHeader) { + const normalized = data[0].map(normalizeHeaderCell); + normalized.forEach((h, i) => { + if (!used.has(i) && VALUE_HEADER_KEYWORDS.some((v) => h && h.includes(v))) { + used.add(i); + } + }); + } + + // Step 2 — numeric/text heuristics fill any column the header didn't resolve. + const sampleRows = dataRows.slice(0, 20); + const numericCols = detectNumericColumns(sampleRows, colCount); + const numericSet = new Set(numericCols); + + if (symbol === null) { + symbol = pickSymbolColumn(sampleRows, colCount, numericSet, used); + if (symbol !== null) used.add(symbol); + } + if (quantity === null) { + quantity = pickNumericColumn(sampleRows, numericCols, used, { + preferIntegers: true, + }); + if (quantity !== null) used.add(quantity); + } + if (unitPrice === null) { + unitPrice = pickNumericColumn(sampleRows, numericCols, used, { + preferDecimals: true, + }); + if (unitPrice !== null) used.add(unitPrice); + } + if (bookCost === null) { + bookCost = pickNumericColumn(sampleRows, numericCols, used, {}); + if (bookCost !== null) used.add(bookCost); + } + + // symbol + quantity are required; fall back to sane defaults so the editor + // always has something to show (the user can correct it). + if (symbol === null) symbol = 0; + if (quantity === null) quantity = symbol === 0 && colCount > 1 ? 1 : 0; + + return { symbol, quantity, unit_price: unitPrice, book_cost: bookCost }; +} + +/** + * Full holdings-CSV analysis: preprocess quoted lines, detect the delimiter and + * header, then the column mapping. Returns the header labels + DATA rows (header + * stripped) so the caller can render a mapping editor + preview and feed the + * rows to `holdingsFromCsvRows`. Null when the content has no usable rows. + * Exported for unit tests. + */ +export function analyzeHoldingsCsv( + rawContent: string +): HoldingCsvAnalysis | null { + const content = preprocessQuotedCSV(rawContent); + const nonEmptyLines = content.split(/\r?\n/).filter((l) => l.trim()); + if (nonEmptyLines.length === 0) return null; + + const delimiter = detectDelimiter(nonEmptyLines.slice(0, 10)); + if (!delimiter) return null; + + const parsed = Papa.parse(content, { delimiter, skipEmptyLines: true }); + const data = (parsed.data as string[][]).filter((r) => + r.some((c) => (c ?? "").trim() !== "") + ); + if (data.length === 0) return null; + + const hasHeader = detectHeader(data[0]); + const mapping = autoDetectHoldingColumns(data, hasHeader); + if (!mapping) return null; + + const colCount = Math.max(...data.map((r) => r.length)); + const headers = hasHeader + ? data[0].map((h, i) => (h ?? "").trim() || `Col ${i}`) + : Array.from({ length: colCount }, (_, i) => `Col ${i}`); + const rows = hasHeader ? data.slice(1) : data; + + return { delimiter, hasHeader, headers, rows, mapping }; +}