// src/pages/DashboardPage/BillingPage/inventoryImport.ts // Invoicing charge-catalog epic, Feature C (Inventory SKU + CSV/JSON import), sub-task C3: the PURE core // behind the Import wizard dialog (InventoryCatalog.tsx, C4). No React, no HTTP, no FHIR types - this is // the part unit-tested directly. It turns an uploaded CSV or JSON inventory file into a normalized // { headers, rows } table (parseImportFile/parseCsv), suggests a columnfield mapping (suggestMapping), // and resolves+validates each data row into an upsert-ready shape (resolveImportRows). The async // SKU-lookup + batched upsert stays in the UI (C4) - nothing here touches the network. // // House convention (matches invoiceLineBuilder.ts / chargeItemDefinition.ts): money crosses the // dollarscents boundary ONLY via money.ts (parseDollarsToCents), and taxable defaults to `true` to // match readInventory's default - an item is taxable unless its source says otherwise. // // CSV parser is a hand-rolled RFC-4180-lite state machine (no papaparse - a lockfile change we're // deliberately avoiding; papaparse is the documented upgrade path if real practice CSVs get exotic). It // handles comma delimiters, "-quoted fields, "" as an escaped literal quote, embedded commas AND embedded // newlines inside quoted fields, and both \r\n and \n line endings. import { parseDollarsToCents } from '../../../utils/money'; export type ImportTable = { headers: string[]; rows: string[][] }; // Parse CSV text into a { headers, rows } table. Character-by-character state machine so quoted fields // (embedded commas, embedded newlines, "" escapes) are handled correctly rather than by a fragile // split(',') / split('\n'). Leading blank lines are skipped until the first non-empty line, which becomes // the header row (trimmed); a trailing blank line (the newline most editors leave at EOF) is dropped. // Data cells are left RAW - resolveImportRows does the per-field trimming/stripping so the raw table can // also drive a faithful preview. export const parseCsv = (text: string): ImportTable => { const records: string[][] = []; let field = ''; let row: string[] = []; let inQuotes = false; const pushField = (): void => { row.push(field); field = ''; }; const pushRow = (): void => { pushField(); records.push(row); row = []; }; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (inQuotes) { if (ch === '"') { if (text[i + 1] === '"') { // "" inside a quoted field is a single literal quote. field += '"'; i++; } else { inQuotes = false; } } else { field += ch; } continue; } if (ch === '"') { inQuotes = true; } else if (ch === ',') { pushField(); } else if (ch === '\r') { pushRow(); if (text[i + 1] === '\n') { i++; // consume the \n of a \r\n pair } } else if (ch === '\n') { pushRow(); } else { field += ch; } } // Flush the final field/row when the file does not end on a newline. if (field !== '' || row.length > 0) { pushRow(); } // Drop trailing fully-empty records (an EOF newline yields one). while (records.length > 0 && records[records.length - 1].every((cell) => cell === '')) { records.pop(); } // First non-empty line is the header row; anything before it is skipped. const headerIndex = records.findIndex((rec) => rec.some((cell) => cell.trim() !== '')); if (headerIndex === -1) { return { headers: [], rows: [] }; } return { headers: records[headerIndex].map((h) => h.trim()), rows: records.slice(headerIndex + 1), }; }; // Coerce a JSON value to a table cell string. Missing/null '' (so a mapped field reads as blank rather // than "null"/"undefined"); numbers/booleans stringify (12.99 "12.99", false "false") so the same // downstream parsing that handles CSV text handles JSON too. const cellToString = (value: unknown): string => { if (value === undefined || value === null) { return ''; } if (typeof value === 'object') { return JSON.stringify(value); } return String(value); }; // Turn a parsed JSON array of objects into a table: headers = the union of every object's keys in // first-seen order; each row = that object's values in header order (a key missing from an object ''). const jsonArrayToTable = (items: unknown[]): ImportTable => { const headers: string[] = []; const seen = new Set(); const objects: Record[] = []; for (const item of items) { const obj = item !== null && typeof item === 'object' && !Array.isArray(item) ? (item as Record) : {}; objects.push(obj); for (const key of Object.keys(obj)) { if (!seen.has(key)) { seen.add(key); headers.push(key); } } } return { headers, rows: objects.map((obj) => headers.map((h) => cellToString(obj[h]))), }; }; // Parse an uploaded file into a table. JSON when the filename ends `.json` (case-insensitive) OR the // trimmed text starts with `[`; otherwise CSV. Throws a friendly Error on empty input or malformed // JSON (or a JSON payload that isn't an array of objects) so the wizard can surface it verbatim. export const parseImportFile = (text: string, filename: string): ImportTable => { const trimmed = text.trim(); if (trimmed === '') { throw new Error('The file is empty. Expected CSV or a JSON array of objects.'); } const looksJson = /\.json$/i.test(filename) || trimmed.startsWith('['); if (looksJson) { let parsed: unknown; try { parsed = JSON.parse(trimmed); } catch { throw new Error('Could not parse the file. Expected CSV or a JSON array of objects.'); } if (!Array.isArray(parsed)) { throw new Error('Could not read the file. Expected a JSON array of objects.'); } return jsonArrayToTable(parsed); } return parseCsv(text); }; export type InventoryFieldKey = 'name' | 'sku' | 'price' | 'taxable'; // Maps each of our inventory fields to a source column header, or null when unmapped. `name` and `price` // are required for a row to be importable (enforced in resolveImportRows, not the type). export type ImportMapping = Record; // Case-insensitive substring heuristics per field. First matching header (in header order) wins. const FIELD_HEURISTICS: Record = { name: ['name', 'item', 'description', 'title'], sku: ['sku', 'upc', 'gtin', 'barcode', 'part', 'code'], price: ['price', 'cost', 'amount', 'rate'], taxable: ['tax', 'taxable'], }; // Best-guess a columnfield mapping from the header names. Each field is matched independently: the first // header whose lowercased text contains any of that field's needles wins; a field with no match null. // (Fields are matched independently, so an ambiguous header like "Item Price" can seed more than one // field - the wizard lets the user correct the mapping before import.) export const suggestMapping = (headers: string[]): ImportMapping => { const lower = headers.map((h) => h.toLowerCase()); const pick = (needles: string[]): string | null => { for (let i = 0; i < headers.length; i++) { if (needles.some((needle) => lower[i].includes(needle))) { return headers[i]; } } return null; }; return { name: pick(FIELD_HEURISTICS.name), sku: pick(FIELD_HEURISTICS.sku), price: pick(FIELD_HEURISTICS.price), taxable: pick(FIELD_HEURISTICS.taxable), }; }; export type ResolvedImportRow = { name: string; sku?: string; priceCents: number; taxable: boolean; valid: boolean; error?: string; }; // Parse a mapped taxable cell. Truthy tokens true, explicit falsy tokens false, and blank/unmapped/ // unrecognized true (inventory is taxable by default, matching readInventory). Case-insensitive. const parseTaxable = (raw: string | undefined): boolean => { if (raw === undefined) { return true; // field unmapped } const v = raw.trim().toLowerCase(); if (v === '') { return true; // blank cell default } if (['true', 'yes', 'y', '1', 'taxable', 'x'].includes(v)) { return true; } if (['false', 'no', 'n', '0'].includes(v)) { return false; } return true; // unrecognized default taxable }; // Parse a mapped price cell to non-negative integer cents, or undefined when missing/blank/unparseable/ // negative. Strips a leading `$` and thousands separators before handing off to parseDollarsToCents // (money.ts is the only dollarscents conversion point). const parsePriceCents = (raw: string | undefined): number | undefined => { if (raw === undefined) { return undefined; } const stripped = raw.trim().replace(/^\$/, '').replace(/,/g, ''); if (stripped === '') { return undefined; } const cents = parseDollarsToCents(stripped); if (cents === undefined || cents < 0) { return undefined; } return cents; }; // Resolve each data row of a table into a validated, upsert-ready inventory row using the column mapping. // name is required (else "Missing name"); price must parse to non-negative integer cents (else "Invalid // price" - but the name is still carried so the preview shows which row failed); sku is trimmed and // omitted when blank; taxable defaults to true. A row is `valid` only when both name and price are good. export const resolveImportRows = (table: ImportTable, mapping: ImportMapping): ResolvedImportRow[] => { const indexOf = (header: string | null): number => header === null ? -1 : table.headers.indexOf(header); const nameIdx = indexOf(mapping.name); const skuIdx = indexOf(mapping.sku); const priceIdx = indexOf(mapping.price); const taxableIdx = indexOf(mapping.taxable); const cell = (row: string[], idx: number): string | undefined => (idx >= 0 ? row[idx] : undefined); return table.rows.map((row) => { const name = (cell(row, nameIdx) ?? '').trim(); const skuRaw = (cell(row, skuIdx) ?? '').trim(); const taxable = parseTaxable(cell(row, taxableIdx)); const sku = skuRaw !== '' ? { sku: skuRaw } : {}; if (name === '') { return { name: '', priceCents: 0, taxable, valid: false, error: 'Missing name' }; } const priceCents = parsePriceCents(cell(row, priceIdx)); if (priceCents === undefined) { return { name, ...sku, priceCents: 0, taxable, valid: false, error: 'Invalid price' }; } return { name, ...sku, priceCents, taxable, valid: true }; }); };