import { DateFormat, type ColumnMapping, type ParsedRow, type ParsedTransaction, } from '@/types/import'; import * as XLSX from 'xlsx'; function detectHeaderRow(columns: unknown[][]): number { if (!columns || columns.length === 0) { return 0; } const firstRowWithValue = columns.map((column) => column.findIndex( (cell) => cell !== undefined && cell !== null && String(cell).length > 1, ), ); const percentages = [0.95, 0.75]; for (const minPercentage of percentages) { const uniqueRows = [...new Set(firstRowWithValue)].sort( (a, b) => a - b, ); for (const rowNumber of uniqueRows) { if (rowNumber === -1) continue; const columnsWithValues = columns.filter((column) => { return ( column[rowNumber] !== undefined && column[rowNumber] !== null && String(column[rowNumber]).length > 1 ); }).length; if (columnsWithValues / columns.length >= minPercentage) { return rowNumber; } } } return 0; } export async function parseFile(file: File): Promise<{ headers: string[]; data: ParsedRow[]; columns: unknown[][]; headerRowIndex: number; }> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { try { const data = e.target?.result; if (!data) { reject(new Error('Failed to read file')); return; } const workbook = XLSX.read(data, { type: 'binary' }); const firstSheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[firstSheetName]; const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1, }) as unknown[][]; if (jsonData.length === 0) { reject(new Error('File is empty')); return; } const maxColumns = Math.max( ...jsonData.map((row) => Array.isArray(row) ? row.length : 0, ), ); const columns: unknown[][] = []; for (let colIndex = 0; colIndex < maxColumns; colIndex++) { const columnData = jsonData.map((row) => Array.isArray(row) ? row[colIndex] : undefined, ); columns.push(columnData); } const headerRowIndex = detectHeaderRow(columns); const letters = [ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ]; const headers = columns.map((column, index) => { const headerValue = column[headerRowIndex]; const headerStr = String(headerValue || '').trim(); if ( headerStr && headerStr.length > 1 && isNaN(Number(headerStr)) ) { return headerStr; } return letters[index] || `Column ${index + 1}`; }); const dataRows = jsonData.slice( headerRowIndex + 1, ) as unknown[][]; const parsedData: ParsedRow[] = dataRows .filter( (row) => Array.isArray(row) && row.some( (cell) => cell !== null && cell !== undefined && cell !== '', ), ) .map((row) => { const obj: ParsedRow = {}; headers.forEach((header, index) => { if (header) { const value = row[index]; obj[header] = value === null || value === undefined ? null : (value as string | number); } }); return obj; }); resolve({ headers, data: parsedData, columns, headerRowIndex }); } catch { reject(new Error('Failed to parse file')); } }; reader.onerror = () => { reject(new Error('Failed to read file')); }; reader.readAsBinaryString(file); }); } export function autoDetectDateFormat( data: ParsedRow[], dateColumnName: string, ): DateFormat | null { if (!data || data.length === 0 || !dateColumnName) { return null; } const formats = [ DateFormat.YearMonthDay, DateFormat.DayMonthYear, DateFormat.MonthDayYear, ]; const sampleSize = Math.min(10, data.length); const scores: Record = { [DateFormat.YearMonthDay]: 0, [DateFormat.DayMonthYear]: 0, [DateFormat.MonthDayYear]: 0, }; for (let i = 0; i < sampleSize; i++) { const dateValue = data[i][dateColumnName]; if (!dateValue) continue; for (const format of formats) { const parsedDate = parseDate(dateValue as string | number, format); if (parsedDate) { scores[format]++; } } } const maxScore = Math.max(...Object.values(scores)); if (maxScore === 0) { return null; } const bestFormat = formats.find((format) => scores[format] === maxScore); if (maxScore >= sampleSize * 0.8) { return bestFormat || null; } return null; } export function autoDetectColumns(headers: string[]): ColumnMapping { const mapping: ColumnMapping = { transaction_date: null, description: null, amount: null, balance: null, }; if (!headers || headers.length === 0) { return mapping; } const lowerHeaders = headers.map((h) => { if (h === null || h === undefined) { return ''; } return String(h).toLowerCase(); }); const datePatterns = [ 'date', 'transaction date', 'fecha', 'transaction_date', 'trans date', 'trans_date', 'f. valor', ]; const descriptionPatterns = [ 'description', 'desc', 'descripcion', 'concept', 'concepto', 'details', 'detalles', 'memo', 'descripción', ]; const amountPatterns = [ 'amount', 'monto', 'value', 'valor', 'total', 'importe', 'quantity', 'cantidad', ]; const balancePatterns = [ 'balance', 'saldo', 'current balance', 'available balance', 'saldo actual', 'saldo disponible', ]; for (let i = 0; i < lowerHeaders.length; i++) { const header = lowerHeaders[i]; const originalHeader = headers[i]; if (!header || typeof header !== 'string') { continue; } if ( !mapping.transaction_date && datePatterns.some((p) => header.includes(p)) ) { mapping.transaction_date = originalHeader; } if ( !mapping.description && descriptionPatterns.some((p) => header.includes(p)) ) { mapping.description = originalHeader; } if (!mapping.amount && amountPatterns.some((p) => header.includes(p))) { mapping.amount = originalHeader; } if (!mapping.balance && balancePatterns.some((p) => header.includes(p))) { mapping.balance = originalHeader; } } return mapping; } export function parseDate( dateStr: string | number, format: DateFormat, ): Date | null { if (!dateStr) { return null; } if (typeof dateStr === 'number') { const excelDate = XLSX.SSF.parse_date_code(dateStr); if (excelDate) { return new Date(excelDate.y, excelDate.m - 1, excelDate.d); } } let str = String(dateStr).trim(); str = str .replace(/\//g, '-') .replace(/\./g, '-') .replace(/[^\d-]/g, ''); let year: number | undefined, month: number | undefined, day: number | undefined; if (str.length === 5) { const dateRegex = /^(\d{1,2})-(\d{1,2})$/; const dateArray = dateRegex.exec(str); if (dateArray) { month = Number( dateArray[format === DateFormat.DayMonthYear ? 2 : 1], ); day = Number(dateArray[format === DateFormat.DayMonthYear ? 1 : 2]); } } else { const parts = str.split('-').filter((p) => p.length > 0); if (parts.length === 3) { switch (format) { case DateFormat.YearMonthDay: [year, month, day] = parts.map(Number); break; case DateFormat.MonthDayYear: [month, day, year] = parts.map(Number); break; case DateFormat.DayMonthYear: [day, month, year] = parts.map(Number); break; } } else if (parts.length === 2) { month = Number(parts[format === DateFormat.DayMonthYear ? 1 : 0]); day = Number(parts[format === DateFormat.DayMonthYear ? 0 : 1]); } } if (year === undefined) { year = new Date().getFullYear(); } if (year < 100) { year += year < 50 ? 2000 : 1900; } if (year === undefined || month === undefined || day === undefined) { return null; } const date = new Date(year, month - 1, day); if ( isNaN(date.getTime()) || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day ) { return null; } return date; } export function parseAmount(amountStr: string | number): number | null { if (typeof amountStr === 'number') { return amountStr; } if (!amountStr) { return null; } let str = String(amountStr).trim(); const dotPos = str.lastIndexOf('.'); const commaPos = str.lastIndexOf(','); const decimalSep = dotPos > commaPos && dotPos !== -1 ? dotPos : commaPos > dotPos && commaPos !== -1 ? commaPos : -1; if (decimalSep !== -1) { const integerPart = str.substring(0, decimalSep).replace(/[^\d]/g, ''); const decimalPart = str.substring(decimalSep + 1); str = integerPart + '.' + decimalPart; } else { str = str.replace(/[^\d]/g, ''); } const amount = parseFloat(str); if (isNaN(amount)) { return null; } return amount; } export function validateTransaction( row: ParsedRow, mapping: ColumnMapping, dateFormat: DateFormat, ): { isValid: boolean; errors: string[] } { const errors: string[] = []; if (!mapping.transaction_date || !row[mapping.transaction_date]) { errors.push('Missing transaction date'); } else { const date = parseDate( row[mapping.transaction_date] as string | number, dateFormat, ); if (!date) { errors.push('Invalid date format'); } } if (!mapping.description || !row[mapping.description]) { errors.push('Missing description'); } if ( !mapping.amount || row[mapping.amount] === null || row[mapping.amount] === undefined ) { errors.push('Missing amount'); } else { const amount = parseAmount(row[mapping.amount] as string | number); if (amount === null) { errors.push('Invalid amount format'); } } return { isValid: errors.length === 0, errors, }; } export function convertRowsToTransactions( rows: ParsedRow[], mapping: ColumnMapping, dateFormat: DateFormat, ): ParsedTransaction[] { const results: ParsedTransaction[] = []; for (const row of rows) { const validation = validateTransaction(row, mapping, dateFormat); if (!validation.isValid) { continue; } const date = parseDate( row[mapping.transaction_date!] as string | number, dateFormat, ); const amount = parseAmount(row[mapping.amount!] as string | number); const description = String(row[mapping.description!] || '').trim(); if (!date || amount === null || !description) { continue; } const formattedDate = date.toISOString().split('T')[0]; let balance: number | null = null; if (mapping.balance && row[mapping.balance]) { const parsedBalance = parseAmount(row[mapping.balance] as string | number); if (parsedBalance !== null) { balance = Math.round(parsedBalance * 100); } } results.push({ transaction_date: formattedDate, description, amount, balance, validationErrors: [], }); } return results; }