From ef2825387475111c4c52e6d1c27b3f3362199241 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 28 May 2026 01:01:34 +0200 Subject: [PATCH 1/3] fix(etoro): support Spanish-language XLSX export format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Long / Short" direction column for Spanish exports where "Acción" contains instrument name instead of "Buy/Sell SYMBOL" - Add missing Spanish header variants: "Tasa de apertura/cierre", "Ganancias (EUR)", "Dividendo neto recibido (EUR)" - Prefer EUR columns when available (avoid unnecessary FX conversion) - Parse interest payments from "Actividad de la cuenta" sheet - Handle parenthesized negatives in parseNumber: (16.63) → -16.63 - Fix detection: async xlsx.read with bookSheets for reliable sheet name detection (works in both Node and browser) - Add comprehensive tests for Spanish format Closes #47 --- src/cli/index.ts | 2 +- src/parsers/csv-utils.ts | 8 +- src/parsers/etoro.ts | 210 ++++++++++++++++++++++------- src/web/main.ts | 4 +- tests/parsers/csv-utils.test.ts | 13 ++ tests/parsers/etoro-xlsx.test.ts | 220 +++++++++++++++++++++++++++++++ 6 files changed, 404 insertions(+), 53 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index c021b74..cb61d3e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -81,7 +81,7 @@ async function parseAndMerge( console.error(` [Revolut XLSX] ${file}: ${statement.trades.length} operaciones, ${statement.cashTransactions.length} transacciones`); continue; } - if (detectEtoroXlsx(buf)) { + if (await detectEtoroXlsx(buf)) { const statement = await parseEtoroXlsx(buf); mergeStatement(merged, statement); brokerNames.push("eToro"); diff --git a/src/parsers/csv-utils.ts b/src/parsers/csv-utils.ts index 95923a4..05f9eac 100644 --- a/src/parsers/csv-utils.ts +++ b/src/parsers/csv-utils.ts @@ -56,9 +56,15 @@ export function parseCsvLine(line: string, delimiter: string): string[] { */ export function parseNumber(val: string): string { // Strip currency symbols (€, $, £, etc.) from any position before parsing - const trimmed = val.trim().replace(/[€$£¥]/g, "").trim(); + let trimmed = val.trim().replace(/[€$£¥]/g, "").trim(); if (!trimmed) return "0"; + // Handle parenthesized negatives: (123.45) → -123.45 + const parenMatch = trimmed.match(/^\((.+)\)$/); + if (parenMatch) { + trimmed = `-${parenMatch[1]!.trim()}`; + } + // If it has both dot and comma, the last one is the decimal separator const lastDot = trimmed.lastIndexOf("."); const lastComma = trimmed.lastIndexOf(","); diff --git a/src/parsers/etoro.ts b/src/parsers/etoro.ts index 6a646f2..d8a4ad8 100644 --- a/src/parsers/etoro.ts +++ b/src/parsers/etoro.ts @@ -32,20 +32,24 @@ type WorkSheet = import("xlsx").WorkSheet; const ACTION_HEADERS = ["action", "acción"]; const AMOUNT_HEADERS = ["amount", "importe", "invested"]; const UNITS_HEADERS = ["units", "units / contracts", "unidades"]; -const OPEN_RATE_HEADERS = ["open rate", "tipo de apertura", "open price"]; -const CLOSE_RATE_HEADERS = ["close rate", "tipo de cierre", "close price"]; -const PROFIT_HEADERS = ["profit", "profit(usd)", "ganancia", "p/l"]; +const OPEN_RATE_HEADERS = ["open rate", "tipo de apertura", "open price", "tasa de apertura"]; +const CLOSE_RATE_HEADERS = ["close rate", "tipo de cierre", "close price", "tasa de cierre"]; +const PROFIT_HEADERS = ["profit", "profit(usd)", "ganancia", "ganancias (usd)", "p/l"]; +const PROFIT_EUR_HEADERS = ["profit(eur)", "ganancias (eur)"]; const OPEN_DATE_HEADERS = ["open date", "fecha de apertura"]; const CLOSE_DATE_HEADERS = ["close date", "fecha de cierre"]; const TYPE_HEADERS = ["type", "tipo"]; const LEVERAGE_HEADERS = ["leverage", "apalancamiento"]; const ISIN_HEADERS = ["isin"]; +const DIRECTION_HEADERS = ["long / short", "long/short"]; // Dividend sheet columns const DIV_DATE_HEADERS = ["date of payment", "fecha de pago", "date"]; const DIV_INSTRUMENT_HEADERS = ["instrument name", "nombre del instrumento", "instrument"]; -const DIV_NET_HEADERS = ["net dividend received (usd)", "dividendo neto recibido", "net dividend", "amount"]; -const DIV_WHT_HEADERS = ["withholding tax amount (usd)", "impuesto retenido", "withholding tax rate (%)"]; +const DIV_NET_HEADERS = ["net dividend received (usd)", "dividendo neto recibido (usd)", "net dividend", "amount"]; +const DIV_NET_EUR_HEADERS = ["net dividend received (eur)", "dividendo neto recibido (eur)"]; +const DIV_WHT_HEADERS = ["withholding tax amount (usd)", "importe de la retención tributaria (usd)", "withholding tax rate (%)", "tasa de retención fiscal (%)"]; +const DIV_WHT_EUR_HEADERS = ["withholding tax amount (eur)", "importe de la retención tributaria (eur)"]; const DIV_ISIN_HEADERS = ["isin"]; // --------------------------------------------------------------------------- @@ -111,11 +115,13 @@ function parseClosedPositions(xlsx: typeof import("xlsx"), sheet: WorkSheet): Tr const openRateCol = findColumn(headers, OPEN_RATE_HEADERS); const closeRateCol = findColumn(headers, CLOSE_RATE_HEADERS); const profitCol = findColumn(headers, PROFIT_HEADERS); + const profitEurCol = findColumn(headers, PROFIT_EUR_HEADERS); const openDateCol = findColumn(headers, OPEN_DATE_HEADERS); const closeDateCol = findColumn(headers, CLOSE_DATE_HEADERS); const typeCol = findColumn(headers, TYPE_HEADERS); const leverageCol = findColumn(headers, LEVERAGE_HEADERS); const isinCol = findColumn(headers, ISIN_HEADERS); + const directionCol = findColumn(headers, DIRECTION_HEADERS); if (actionCol < 0 || unitsCol < 0) return []; @@ -141,16 +147,38 @@ function parseClosedPositions(xlsx: typeof import("xlsx"), sheet: WorkSheet): Tr const isCfd = (!isNaN(leverageNum) && leverageNum > 1) || rowType.includes("cfd") || rowType.includes("commodit"); const assetCat = isCfd ? "CFD" as const : "STK" as const; + // Two eToro formats: + // English: "Action" = "Buy AAPL" (direction + symbol combined) + // Spanish: "Acción" = instrument name, "Long / Short" = direction const actionStr = row[actionCol] ?? ""; - const parsed = parseAction(actionStr); - if (!parsed) continue; + let symbol: string; + let direction: "BUY" | "SELL"; + + if (directionCol >= 0) { + // Spanish format: Acción = name, Long/Short = direction + symbol = actionStr.trim(); + const dirStr = (row[directionCol] ?? "").toLowerCase().trim(); + direction = dirStr === "short" ? "SELL" : "BUY"; + } else { + const parsed = parseAction(actionStr); + if (!parsed) continue; + symbol = parsed.symbol; + direction = parsed.direction; + } + + if (!symbol) continue; const isin = isinCol >= 0 ? (row[isinCol] ?? "").trim() : ""; const units = (row[unitsCol] ?? "0").trim(); const openRate = openRateCol >= 0 ? (row[openRateCol] ?? "0").trim() : "0"; const closeRate = closeRateCol >= 0 ? (row[closeRateCol] ?? "0").trim() : "0"; const amount = amountCol >= 0 ? (row[amountCol] ?? "0").trim() : "0"; - const profit = profitCol >= 0 ? (row[profitCol] ?? "0").trim() : "0"; + // Prefer EUR profit if available (Spanish export has both USD and EUR) + const profitRaw = profitEurCol >= 0 + ? (row[profitEurCol] ?? "0").trim() + : profitCol >= 0 ? (row[profitCol] ?? "0").trim() : "0"; + const profit = parseNumber(profitRaw); + const currency = profitEurCol >= 0 && (row[profitEurCol] ?? "").trim() ? "EUR" : "USD"; const openDate = openDateCol >= 0 ? parseEtoroDate(row[openDateCol] ?? "") : ""; const closeDate = closeDateCol >= 0 ? parseEtoroDate(row[closeDateCol] ?? "") : ""; @@ -163,13 +191,13 @@ function parseClosedPositions(xlsx: typeof import("xlsx"), sheet: WorkSheet): Tr // Buy leg (opening) trades.push({ - tradeID: `etoro-open-${openDate}-${parsed.symbol}-${i}`, + tradeID: `etoro-open-${openDate}-${symbol}-${i}`, accountId: "", - symbol: parsed.symbol, - description: parsed.symbol, + symbol, + description: symbol, isin, assetCategory: assetCat, - currency: "USD", + currency, tradeDate: openDate, settlementDate: openDate, quantity: `${absUnits}`, @@ -179,10 +207,10 @@ function parseClosedPositions(xlsx: typeof import("xlsx"), sheet: WorkSheet): Tr cost: `-${amount}`, fifoPnlRealized: "0", fxRateToBase: "1", - buySell: "BUY", + buySell: direction === "SELL" ? "SELL" : "BUY", openCloseIndicator: "O", exchange: "ETORO", - commissionCurrency: "USD", + commissionCurrency: currency, commission: "0", taxes: "0", multiplier: "1", @@ -194,13 +222,13 @@ function parseClosedPositions(xlsx: typeof import("xlsx"), sheet: WorkSheet): Tr const proceeds = isNaN(amountNum) || isNaN(profitNum) ? "0" : `${amountNum + profitNum}`; trades.push({ - tradeID: `etoro-close-${closeDate}-${parsed.symbol}-${i}`, + tradeID: `etoro-close-${closeDate}-${symbol}-${i}`, accountId: "", - symbol: parsed.symbol, - description: parsed.symbol, + symbol, + description: symbol, isin, assetCategory: assetCat, - currency: "USD", + currency, tradeDate: closeDate, settlementDate: closeDate, quantity: `-${absUnits}`, @@ -210,10 +238,10 @@ function parseClosedPositions(xlsx: typeof import("xlsx"), sheet: WorkSheet): Tr cost: "0", fifoPnlRealized: profit, fxRateToBase: "1", - buySell: "SELL", + buySell: direction === "SELL" ? "BUY" : "SELL", openCloseIndicator: "C", exchange: "ETORO", - commissionCurrency: "USD", + commissionCurrency: currency, commission: "0", taxes: "0", multiplier: "1", @@ -235,10 +263,18 @@ function parseDividends(xlsx: typeof import("xlsx"), sheet: WorkSheet): CashTran const dateCol = findColumn(headers, DIV_DATE_HEADERS); const instrumentCol = findColumn(headers, DIV_INSTRUMENT_HEADERS); const netCol = findColumn(headers, DIV_NET_HEADERS); + const netEurCol = findColumn(headers, DIV_NET_EUR_HEADERS); const whtCol = findColumn(headers, DIV_WHT_HEADERS); + const whtEurCol = findColumn(headers, DIV_WHT_EUR_HEADERS); const isinCol = findColumn(headers, DIV_ISIN_HEADERS); - if (dateCol < 0 || netCol < 0) return []; + if (dateCol < 0 || (netCol < 0 && netEurCol < 0)) return []; + + // Prefer EUR columns if available (Spanish export provides both USD and EUR) + const useEur = netEurCol >= 0; + const effectiveNetCol = useEur ? netEurCol : netCol; + const effectiveWhtCol = useEur ? whtEurCol : whtCol; + const currency = useEur ? "EUR" : "USD"; const cashTransactions: CashTransaction[] = []; @@ -251,33 +287,55 @@ function parseDividends(xlsx: typeof import("xlsx"), sheet: WorkSheet): CashTran const tradeDate = parseEtoroDate(dateStr); const instrument = instrumentCol >= 0 ? (row[instrumentCol] ?? "").trim() : ""; - const netAmount = (row[netCol] ?? "0").trim(); - const whtAmount = whtCol >= 0 ? (row[whtCol] ?? "0").trim() : "0"; + const netAmount = (row[effectiveNetCol] ?? "0").trim(); const isin = isinCol >= 0 ? (row[isinCol] ?? "").trim() : ""; const netNum = parseFloat(parseNumber(netAmount)); if (isNaN(netNum) || netNum === 0) continue; // Compute gross dividend and withholding tax - const whtNum = parseFloat(parseNumber(whtAmount)); - let grossAmount = netAmount; + let grossAmount: string; let taxAmount = "0"; - if (!isNaN(whtNum) && whtNum !== 0) { - // WHT might be a percentage or absolute amount - const isPercentage = whtNum > 0 && whtNum <= 100 && headers[whtCol]?.toLowerCase().includes("%"); - if (isPercentage) { - const grossNum = netNum / (1 - whtNum / 100); - grossAmount = grossNum.toFixed(2); - taxAmount = `-${(grossNum * (whtNum / 100)).toFixed(2)}`; + + if (effectiveWhtCol >= 0) { + const whtRaw = (row[effectiveWhtCol] ?? "0").trim(); + const whtNum = parseFloat(parseNumber(whtRaw)); + + if (!isNaN(whtNum) && whtNum !== 0) { + // Check if WHT column is a percentage (header contains "%") or absolute + const whtHeader = (headers[effectiveWhtCol] ?? "").toLowerCase(); + const isPercentage = whtNum > 0 && whtNum <= 100 && whtHeader.includes("%"); + if (isPercentage) { + const grossNum = netNum / (1 - whtNum / 100); + grossAmount = grossNum.toFixed(4); + taxAmount = `-${(grossNum * (whtNum / 100)).toFixed(4)}`; + } else { + const absWht = Math.abs(whtNum); + grossAmount = (netNum + absWht).toFixed(4); + taxAmount = whtNum > 0 ? `-${whtNum}` : `${whtNum}`; + } } else { - const absWht = Math.abs(whtNum); - grossAmount = (netNum + absWht).toFixed(2); - taxAmount = whtNum > 0 ? `-${whtNum}` : `${whtNum}`; + grossAmount = netAmount; + } + } else { + // No WHT column for this currency — try the percentage column as fallback + const pctCol = findColumn(headers, ["withholding tax rate (%)", "tasa de retención fiscal (%)"]); + if (pctCol >= 0) { + const pctRaw = (row[pctCol] ?? "0").replace(/%/g, "").trim(); + const pctNum = parseFloat(parseNumber(pctRaw)); + if (!isNaN(pctNum) && pctNum > 0) { + const grossNum = netNum / (1 - pctNum / 100); + grossAmount = grossNum.toFixed(4); + taxAmount = `-${(grossNum * (pctNum / 100)).toFixed(4)}`; + } else { + grossAmount = netAmount; + } + } else { + grossAmount = netAmount; } } // Dividend (gross amount — downstream engine expects gross) - // Include ISIN country code so dividend engine can extract withholding country const isinCountry = isin.length >= 2 ? isin.slice(0, 2).toUpperCase() : ""; cashTransactions.push({ transactionID: `etoro-div-${tradeDate}-${instrument}-${i}`, @@ -285,7 +343,7 @@ function parseDividends(xlsx: typeof import("xlsx"), sheet: WorkSheet): CashTran symbol: instrument, description: `${isinCountry} Dividend - ${instrument}`, isin, - currency: "USD", + currency, dateTime: tradeDate, settleDate: tradeDate, amount: grossAmount, @@ -301,7 +359,7 @@ function parseDividends(xlsx: typeof import("xlsx"), sheet: WorkSheet): CashTran symbol: instrument, description: `${isinCountry} WHT - ${instrument}`, isin, - currency: "USD", + currency, dateTime: tradeDate, settleDate: tradeDate, amount: taxAmount, @@ -314,6 +372,61 @@ function parseDividends(xlsx: typeof import("xlsx"), sheet: WorkSheet): CashTran return cashTransactions; } +// --------------------------------------------------------------------------- +// Account Activity parser (interest income) +// --------------------------------------------------------------------------- + +const ACTIVITY_DATE_HEADERS = ["date", "fecha"]; +const ACTIVITY_TYPE_HEADERS = ["type", "tipo"]; +const ACTIVITY_AMOUNT_HEADERS = ["amount", "importe"]; + +function parseAccountActivity(xlsx: typeof import("xlsx"), sheet: WorkSheet): CashTransaction[] { + const rows = sheetToRows(xlsx, sheet); + if (rows.length < 2) return []; + + const headers = rows[0]!; + const dateCol = findColumn(headers, ACTIVITY_DATE_HEADERS); + const typeCol = findColumn(headers, ACTIVITY_TYPE_HEADERS); + const amountCol = findColumn(headers, ACTIVITY_AMOUNT_HEADERS); + + if (dateCol < 0 || typeCol < 0 || amountCol < 0) return []; + + const cashTransactions: CashTransaction[] = []; + + for (let i = 1; i < rows.length; i++) { + const row = rows[i]!; + if (!row.length) continue; + + const typeStr = (row[typeCol] ?? "").toLowerCase().trim(); + // Only capture interest payments + if (!typeStr.includes("interest") && !typeStr.includes("intereses")) continue; + + const dateStr = (row[dateCol] ?? "").trim(); + if (!dateStr) continue; + + const tradeDate = parseEtoroDate(dateStr); + const amountStr = (row[amountCol] ?? "0").trim(); + const amountNum = parseFloat(parseNumber(amountStr)); + if (isNaN(amountNum) || amountNum === 0) continue; + + cashTransactions.push({ + transactionID: `etoro-interest-${tradeDate}-${i}`, + accountId: "", + symbol: "CASH", + description: "Interest Payment", + isin: "", + currency: "USD", + dateTime: tradeDate, + settleDate: tradeDate, + amount: `${amountNum}`, + fxRateToBase: "1", + type: "Broker Interest Received", + }); + } + + return cashTransactions; +} + // --------------------------------------------------------------------------- // XLSX detection and parsing // --------------------------------------------------------------------------- @@ -328,9 +441,11 @@ export async function parseEtoroXlsx(data: Buffer | Uint8Array): Promise { if (data.length < 4 || data[0] !== 0x50 || data[1] !== 0x4B) return false; try { - // Lazy import check — if xlsx isn't available, can't parse - // For detection, check if it contains eToro-specific content - const textSlice = Buffer.from(data.slice(0, Math.min(data.length, 100000))).toString("utf-8", 0, Math.min(data.length, 100000)); - return textSlice.toLowerCase().includes("closed position") || - textSlice.toLowerCase().includes("posiciones cerradas") || - textSlice.toLowerCase().includes("etoro"); + const xlsx = await import("xlsx"); + const wb = xlsx.read(data, { type: "buffer", bookSheets: true }); + const names = wb.SheetNames.map((s) => s.toLowerCase()); + return names.some((n) => n.includes("closed position") || n.includes("posiciones cerradas")); } catch { return false; } diff --git a/src/web/main.ts b/src/web/main.ts index db2d4d6..085f5bc 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -340,7 +340,7 @@ async function previewDetectBroker(file: File): Promise { const uint8 = await getFileBytes(file); let result: string | null = null; if (await detectRevolutXlsx(uint8)) result = "Revolut"; - else if (detectEtoroXlsx(uint8)) result = "eToro"; + else if (await detectEtoroXlsx(uint8)) result = "eToro"; else result = detectBroker(new TextDecoder("utf-8").decode(uint8))?.name ?? null; detectionCache.set(key, result); return result; @@ -423,7 +423,7 @@ async function parseFiles(): Promise { continue; } - if (detectEtoroXlsx(uint8)) { + if (await detectEtoroXlsx(uint8)) { const statement = await parseEtoroXlsx(uint8); mergeStatement(merged, statement); brokerNames.push("eToro"); diff --git a/tests/parsers/csv-utils.test.ts b/tests/parsers/csv-utils.test.ts index 42f51a8..66513ff 100644 --- a/tests/parsers/csv-utils.test.ts +++ b/tests/parsers/csv-utils.test.ts @@ -94,6 +94,19 @@ describe("parseNumber", () => { it("should handle negative number with no separator", () => { expect(parseNumber("-50")).toBe("-50"); }); + + it("should handle parenthesized negatives", () => { + expect(parseNumber("(16.63)")).toBe("-16.63"); + expect(parseNumber("(1,234.56)")).toBe("-1234.56"); + expect(parseNumber("(0.07)")).toBe("-0.07"); + expect(parseNumber("( 42 )")).toBe("-42"); + }); + + it("should strip currency symbols", () => { + expect(parseNumber("€1.234,56")).toBe("1234.56"); + expect(parseNumber("$100.50")).toBe("100.50"); + expect(parseNumber("£(50.00)")).toBe("-50.00"); + }); }); describe("convertDateDMY", () => { diff --git a/tests/parsers/etoro-xlsx.test.ts b/tests/parsers/etoro-xlsx.test.ts index ee935ce..df829b8 100644 --- a/tests/parsers/etoro-xlsx.test.ts +++ b/tests/parsers/etoro-xlsx.test.ts @@ -410,4 +410,224 @@ describe("eToro XLSX parsing", () => { expect(result.trades).toHaveLength(2); }); }); + + describe("parseEtoroXlsx — Spanish export format (full)", () => { + const SPANISH_CLOSED_HEADER = [ + "ID de posición", "Acción", "Long / Short", "Importe", "Unidades", + "Fecha de apertura", "Fecha de cierre", "Apalancamiento", + "Comisiones de diferencial (USD)", "Diferencial de mercado (USD)", + "Ganancias (USD)", "Ganancias (EUR)", "Tipo de cambio de apertura (USD)", + "Tipo de cambio al cierre (USD)", "Tasa de apertura", "Tasa de cierre", + "Tasa de Take Profit", "Tasa de Stop Loss", + "Comisiones nocturnas y dividendos", "Copiado desde", "Tipo", "ISIN", "Notas", + ]; + + const SPANISH_DIVIDENDS_HEADER = [ + "Fecha de pago", "Nombre del instrumento", "Dividendo neto recibido (USD)", + "Net dividends", "Currency", "Con deducción/sin deducción", + "Dividendos con deducción (AUD)", "Dividendo neto recibido (EUR)", + "Tasa de retención fiscal (%)", "Importe de la retención tributaria (USD)", + "Importe de la retención tributaria (EUR)", "ID de posición", "Tipo", "ISIN", + ]; + + const SPANISH_ACTIVITY_HEADER = [ + "Fecha", "Tipo", "Detalles", "Importe", "Unidades", + "Cambio de capital realizado", "Capital realizado", "Saldo", + "ID de posición", "Tipo de activo", "Importe no retirable", + ]; + + function buildSpanishWorkbook(opts: { + closedPositions?: string[][]; + dividends?: string[][]; + activity?: string[][]; + }): Uint8Array { + const wb = XLSX.utils.book_new(); + if (opts.closedPositions) { + const ws = XLSX.utils.aoa_to_sheet(opts.closedPositions); + XLSX.utils.book_append_sheet(wb, ws, "Posiciones cerradas"); + } + if (opts.dividends) { + const ws = XLSX.utils.aoa_to_sheet(opts.dividends); + XLSX.utils.book_append_sheet(wb, ws, "Dividendos"); + } + if (opts.activity) { + const ws = XLSX.utils.aoa_to_sheet(opts.activity); + XLSX.utils.book_append_sheet(wb, ws, "Actividad de la cuenta"); + } + const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array; + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + + it("should detect Spanish eToro workbook", () => { + const data = buildSpanishWorkbook({ + closedPositions: [SPANISH_CLOSED_HEADER], + }); + expect(detectEtoroXlsx(data)).toBe(true); + }); + + it("should parse Long positions using 'Acción' as symbol and 'Long / Short' for direction", async () => { + const data = buildSpanishWorkbook({ + closedPositions: [ + SPANISH_CLOSED_HEADER, + // ID, Acción, Long/Short, Importe, Unidades, FechaApertura, FechaCierre, Apalancamiento, + // ComDif(USD), DifMercado(USD), Ganancias(USD), Ganancias(EUR), TipoCambioAp, TipoCambioCi, + // TasaApertura, TasaCierre, TP, SL, Comisiones, Copiado, Tipo, ISIN, Notas + ["123", "Apple Inc (AAPL)", "Long", "1000", "5.5", "15/03/2024 09:30:00", "20/09/2025 14:00:00", + "1", "0", "-0.5", "100", "91.50", "1.08", "1.10", "180", "200", "0", "0", "0", "-", "Acciones", "US0378331005", ""], + ], + }); + + const result = await parseEtoroXlsx(data); + expect(result.trades).toHaveLength(2); + + const buy = result.trades[0]!; + expect(buy.buySell).toBe("BUY"); + expect(buy.symbol).toBe("Apple Inc (AAPL)"); + expect(buy.isin).toBe("US0378331005"); + expect(buy.currency).toBe("EUR"); + expect(buy.quantity).toBe("5.5"); + expect(buy.tradePrice).toBe("180"); + expect(buy.tradeDate).toBe("20240315"); + + const sell = result.trades[1]!; + expect(sell.buySell).toBe("SELL"); + expect(sell.tradeDate).toBe("20250920"); + expect(sell.tradePrice).toBe("200"); + expect(sell.fifoPnlRealized).toBe("91.50"); + expect(sell.currency).toBe("EUR"); + }); + + it("should parse Short positions with inverted buy/sell legs", async () => { + const data = buildSpanishWorkbook({ + closedPositions: [ + SPANISH_CLOSED_HEADER, + ["456", "Tesla (TSLA)", "Short", "2000", "10", "01/02/2025 10:00:00", "15/06/2025 16:00:00", + "1", "0", "0", "50", "45", "1.09", "1.10", "250", "200", "0", "0", "0", "-", "Acciones", "US88160R1014", ""], + ], + }); + + const result = await parseEtoroXlsx(data); + expect(result.trades).toHaveLength(2); + // Short: opening leg is SELL, closing leg is BUY + expect(result.trades[0]!.buySell).toBe("SELL"); + expect(result.trades[1]!.buySell).toBe("BUY"); + }); + + it("should handle parenthesized negative profits like (16.63)", async () => { + const data = buildSpanishWorkbook({ + closedPositions: [ + SPANISH_CLOSED_HEADER, + ["789", "ProSieben (PSM.DE)", "Long", "83.11", "7.84", "08/02/2023 15:36:21", "26/08/2025 14:04:10", + "1", "0", "-0.07", "(16.63)", "(14.29)", "1.07", "1.16", "9.89", "8.07", "0", "0", "0.95", "-", "Acciones", "DE000PSM7770", ""], + ], + }); + + const result = await parseEtoroXlsx(data); + expect(result.trades).toHaveLength(2); + const sell = result.trades[1]!; + // Proceeds = amount + profit = 83.11 + (-14.29) = 68.82 + expect(parseFloat(sell.proceeds)).toBeCloseTo(68.82, 2); + expect(sell.fifoPnlRealized).toBe("-14.29"); + }); + + it("should use EUR dividend columns when available", async () => { + const data = buildSpanishWorkbook({ + closedPositions: [SPANISH_CLOSED_HEADER], + dividends: [ + SPANISH_DIVIDENDS_HEADER, + // FechaPago, Instrumento, NetUSD, NetDividends, Currency, Deducción, DedAUD, + // NetEUR, TasaRet%, RetUSD, RetEUR, ID, Tipo, ISIN + ["02/01/2025", "Paramount Skydance Corp", "1.08", "0", "", "-", "-", + "1.0518", "15 %", "0.1906", "0.1856", "123", "Stocks", "US69932A2042"], + ], + }); + + const result = await parseEtoroXlsx(data); + const divs = result.cashTransactions.filter((c) => c.type === "Dividends"); + const whts = result.cashTransactions.filter((c) => c.type === "Withholding Tax"); + + expect(divs).toHaveLength(1); + expect(whts).toHaveLength(1); + expect(divs[0]!.currency).toBe("EUR"); + expect(divs[0]!.isin).toBe("US69932A2042"); + expect(divs[0]!.description).toContain("US"); + // Gross = net / (1 - rate) = 1.0518 / (1 - 0.15) = ~1.2374 + expect(parseFloat(divs[0]!.amount)).toBeCloseTo(1.2374, 3); + expect(whts[0]!.currency).toBe("EUR"); + }); + + it("should handle 0% withholding (UK dividends)", async () => { + const data = buildSpanishWorkbook({ + closedPositions: [SPANISH_CLOSED_HEADER], + dividends: [ + SPANISH_DIVIDENDS_HEADER, + ["21/03/2025", "easyJet", "12.41", "0", "", "-", "-", + "11.4747", "0 %", "0.0000", "0.0000", "630", "Stocks", "GB00B7KR2P84"], + ], + }); + + const result = await parseEtoroXlsx(data); + const divs = result.cashTransactions.filter((c) => c.type === "Dividends"); + const whts = result.cashTransactions.filter((c) => c.type === "Withholding Tax"); + + expect(divs).toHaveLength(1); + expect(whts).toHaveLength(0); + // 0% WHT → gross = net + expect(divs[0]!.amount).toBe("11.4747"); + }); + + it("should parse interest from Account Activity sheet", async () => { + const data = buildSpanishWorkbook({ + closedPositions: [SPANISH_CLOSED_HEADER], + activity: [ + SPANISH_ACTIVITY_HEADER, + ["01/01/2025 06:01:43", "Pago de intereses", "-", "0.26", "-", "0.26", "7843.61", "0.26", "-", "-", "0"], + ["01/03/2025 06:01:15", "Pago de intereses", "-", "0.10", "-", "0.10", "7903.58", "60.23", "-", "-", "0"], + ["02/01/2025 00:19:24", "Dividendo", "PSKY/USD", "1.08", "-", "1.08", "7844.69", "1.34", "123", "Acciones", "0"], + ], + }); + + const result = await parseEtoroXlsx(data); + const interest = result.cashTransactions.filter((c) => c.type === "Broker Interest Received"); + expect(interest).toHaveLength(2); + expect(interest[0]!.amount).toBe("0.26"); + expect(interest[0]!.dateTime).toBe("20250101"); + expect(interest[1]!.amount).toBe("0.1"); + }); + + it("should skip non-interest rows in Account Activity", async () => { + const data = buildSpanishWorkbook({ + closedPositions: [SPANISH_CLOSED_HEADER], + activity: [ + SPANISH_ACTIVITY_HEADER, + ["02/01/2025 00:19:24", "Dividendo", "PSKY/USD", "1.08", "-", "1.08", "7844.69", "1.34", "123", "Acciones", "0"], + ["13/03/2025 15:27:24", "Comisión", "Al abrir", "(1.00)", "-", "0", "7908.06", "1.00", "123", "Acciones", "0"], + ["13/03/2025 15:27:24", "Posición abierta", "PAH3.DE/EUR", "63.71", "1.54", "0", "7908.06", "1.00", "123", "Acciones", "0"], + ], + }); + + const result = await parseEtoroXlsx(data); + const interest = result.cashTransactions.filter((c) => c.type === "Broker Interest Received"); + expect(interest).toHaveLength(0); + }); + + it("should classify CFDs by type or leverage in Spanish format", async () => { + const data = buildSpanishWorkbook({ + closedPositions: [ + SPANISH_CLOSED_HEADER, + // CFD by type + ["111", "Energy Transfer LP", "Long", "500", "100", "01/01/2025", "01/03/2025", + "1", "0", "0", "50", "45", "1.08", "1.10", "5", "5.5", "0", "0", "0", "-", "CFD", "US29273V1008", ""], + // CFD by leverage > 1 + ["222", "AAPL Leveraged", "Long", "1000", "5", "01/01/2025", "01/06/2025", + "5", "0", "0", "200", "180", "1.08", "1.10", "180", "220", "0", "0", "0", "-", "Acciones", "US0378331005", ""], + ], + }); + + const result = await parseEtoroXlsx(data); + expect(result.trades).toHaveLength(4); + expect(result.trades[0]!.assetCategory).toBe("CFD"); + expect(result.trades[2]!.assetCategory).toBe("CFD"); + }); + }); }); From 47ab6e6ca4882a09bd50c0a0e17b416054c61270 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 28 May 2026 01:03:43 +0200 Subject: [PATCH 2/3] fix(tests): make detectEtoroXlsx tests async after signature change --- tests/parsers/etoro-xlsx.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/parsers/etoro-xlsx.test.ts b/tests/parsers/etoro-xlsx.test.ts index df829b8..23ff384 100644 --- a/tests/parsers/etoro-xlsx.test.ts +++ b/tests/parsers/etoro-xlsx.test.ts @@ -47,24 +47,24 @@ function buildEtoroWorkbook(opts: { describe("eToro XLSX parsing", () => { describe("detectEtoroXlsx", () => { - it("should detect a valid eToro XLSX", () => { + it("should detect a valid eToro XLSX", async () => { const data = buildEtoroWorkbook({ closedPositions: [ CLOSED_POSITIONS_HEADER, ["Buy AAPL", "1000", "5", "180", "195", "82.50", "15/03/2025 09:30:00", "20/09/2025 14:00:00", "Stocks", "1", "US0378331005"], ], }); - expect(detectEtoroXlsx(data)).toBe(true); + expect(await detectEtoroXlsx(data)).toBe(true); }); - it("should reject non-ZIP data", () => { + it("should reject non-ZIP data", async () => { const data = new Uint8Array([0x00, 0x01, 0x02, 0x03]); - expect(detectEtoroXlsx(data)).toBe(false); + expect(await detectEtoroXlsx(data)).toBe(false); }); - it("should reject empty data", () => { + it("should reject empty data", async () => { const data = new Uint8Array(0); - expect(detectEtoroXlsx(data)).toBe(false); + expect(await detectEtoroXlsx(data)).toBe(false); }); }); @@ -458,11 +458,11 @@ describe("eToro XLSX parsing", () => { return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); } - it("should detect Spanish eToro workbook", () => { + it("should detect Spanish eToro workbook", async () => { const data = buildSpanishWorkbook({ closedPositions: [SPANISH_CLOSED_HEADER], }); - expect(detectEtoroXlsx(data)).toBe(true); + expect(await detectEtoroXlsx(data)).toBe(true); }); it("should parse Long positions using 'Acción' as symbol and 'Long / Short' for direction", async () => { From bc9cba799f0b2282054f5dc7c8dbb23f62731e65 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 28 May 2026 01:20:31 +0200 Subject: [PATCH 3/3] fix(etoro): address review findings for Spanish export support - Tighten parseNumber regex to only match numeric content in parens - Use Decimal.js for proceeds calculation instead of parseFloat - Split WHT into separate amount/rate columns with explicit priority - Add comment documenting USD interest assumption --- src/parsers/csv-utils.ts | 2 +- src/parsers/etoro.ts | 57 ++++++++++++++++++---------------------- 2 files changed, 26 insertions(+), 33 deletions(-) diff --git a/src/parsers/csv-utils.ts b/src/parsers/csv-utils.ts index 05f9eac..ea5eb5c 100644 --- a/src/parsers/csv-utils.ts +++ b/src/parsers/csv-utils.ts @@ -60,7 +60,7 @@ export function parseNumber(val: string): string { if (!trimmed) return "0"; // Handle parenthesized negatives: (123.45) → -123.45 - const parenMatch = trimmed.match(/^\((.+)\)$/); + const parenMatch = trimmed.match(/^\(([0-9.,\s-]+)\)$/); if (parenMatch) { trimmed = `-${parenMatch[1]!.trim()}`; } diff --git a/src/parsers/etoro.ts b/src/parsers/etoro.ts index d8a4ad8..ef0d9bb 100644 --- a/src/parsers/etoro.ts +++ b/src/parsers/etoro.ts @@ -17,6 +17,7 @@ * parsing, use the parseEtoroXlsx() function with the xlsx library. */ +import Decimal from "decimal.js"; import type { BrokerParser, Statement } from "../types/broker.js"; import type { Trade, CashTransaction } from "../types/ibkr.js"; import { findColumn, parseNumber } from "./csv-utils.js"; @@ -48,7 +49,8 @@ const DIV_DATE_HEADERS = ["date of payment", "fecha de pago", "date"]; const DIV_INSTRUMENT_HEADERS = ["instrument name", "nombre del instrumento", "instrument"]; const DIV_NET_HEADERS = ["net dividend received (usd)", "dividendo neto recibido (usd)", "net dividend", "amount"]; const DIV_NET_EUR_HEADERS = ["net dividend received (eur)", "dividendo neto recibido (eur)"]; -const DIV_WHT_HEADERS = ["withholding tax amount (usd)", "importe de la retención tributaria (usd)", "withholding tax rate (%)", "tasa de retención fiscal (%)"]; +const DIV_WHT_AMOUNT_HEADERS = ["withholding tax amount (usd)", "importe de la retención tributaria (usd)"]; +const DIV_WHT_RATE_HEADERS = ["withholding tax rate (%)", "tasa de retención fiscal (%)"]; const DIV_WHT_EUR_HEADERS = ["withholding tax amount (eur)", "importe de la retención tributaria (eur)"]; const DIV_ISIN_HEADERS = ["isin"]; @@ -217,9 +219,9 @@ function parseClosedPositions(xlsx: typeof import("xlsx"), sheet: WorkSheet): Tr }); // Sell leg (closing) - const amountNum = parseFloat(parseNumber(amount)); - const profitNum = parseFloat(parseNumber(profit)); - const proceeds = isNaN(amountNum) || isNaN(profitNum) ? "0" : `${amountNum + profitNum}`; + const amountDec = new Decimal(parseNumber(amount) || "0"); + const profitDec = new Decimal(profit || "0"); + const proceeds = amountDec.plus(profitDec).toString(); trades.push({ tradeID: `etoro-close-${closeDate}-${symbol}-${i}`, @@ -264,7 +266,8 @@ function parseDividends(xlsx: typeof import("xlsx"), sheet: WorkSheet): CashTran const instrumentCol = findColumn(headers, DIV_INSTRUMENT_HEADERS); const netCol = findColumn(headers, DIV_NET_HEADERS); const netEurCol = findColumn(headers, DIV_NET_EUR_HEADERS); - const whtCol = findColumn(headers, DIV_WHT_HEADERS); + const whtAmountCol = findColumn(headers, DIV_WHT_AMOUNT_HEADERS); + const whtRateCol = findColumn(headers, DIV_WHT_RATE_HEADERS); const whtEurCol = findColumn(headers, DIV_WHT_EUR_HEADERS); const isinCol = findColumn(headers, DIV_ISIN_HEADERS); @@ -273,7 +276,8 @@ function parseDividends(xlsx: typeof import("xlsx"), sheet: WorkSheet): CashTran // Prefer EUR columns if available (Spanish export provides both USD and EUR) const useEur = netEurCol >= 0; const effectiveNetCol = useEur ? netEurCol : netCol; - const effectiveWhtCol = useEur ? whtEurCol : whtCol; + // WHT: prefer absolute EUR amount → absolute USD amount → percentage rate + const effectiveWhtCol = useEur ? whtEurCol : whtAmountCol; const currency = useEur ? "EUR" : "USD"; const cashTransactions: CashTransaction[] = []; @@ -298,41 +302,30 @@ function parseDividends(xlsx: typeof import("xlsx"), sheet: WorkSheet): CashTran let taxAmount = "0"; if (effectiveWhtCol >= 0) { + // Absolute WHT amount column available const whtRaw = (row[effectiveWhtCol] ?? "0").trim(); const whtNum = parseFloat(parseNumber(whtRaw)); if (!isNaN(whtNum) && whtNum !== 0) { - // Check if WHT column is a percentage (header contains "%") or absolute - const whtHeader = (headers[effectiveWhtCol] ?? "").toLowerCase(); - const isPercentage = whtNum > 0 && whtNum <= 100 && whtHeader.includes("%"); - if (isPercentage) { - const grossNum = netNum / (1 - whtNum / 100); - grossAmount = grossNum.toFixed(4); - taxAmount = `-${(grossNum * (whtNum / 100)).toFixed(4)}`; - } else { - const absWht = Math.abs(whtNum); - grossAmount = (netNum + absWht).toFixed(4); - taxAmount = whtNum > 0 ? `-${whtNum}` : `${whtNum}`; - } + const absWht = Math.abs(whtNum); + grossAmount = (netNum + absWht).toFixed(4); + taxAmount = whtNum > 0 ? `-${whtNum}` : `${whtNum}`; } else { grossAmount = netAmount; } - } else { - // No WHT column for this currency — try the percentage column as fallback - const pctCol = findColumn(headers, ["withholding tax rate (%)", "tasa de retención fiscal (%)"]); - if (pctCol >= 0) { - const pctRaw = (row[pctCol] ?? "0").replace(/%/g, "").trim(); - const pctNum = parseFloat(parseNumber(pctRaw)); - if (!isNaN(pctNum) && pctNum > 0) { - const grossNum = netNum / (1 - pctNum / 100); - grossAmount = grossNum.toFixed(4); - taxAmount = `-${(grossNum * (pctNum / 100)).toFixed(4)}`; - } else { - grossAmount = netAmount; - } + } else if (whtRateCol >= 0) { + // Fall back to percentage rate column + const pctRaw = (row[whtRateCol] ?? "0").replace(/%/g, "").trim(); + const pctNum = parseFloat(parseNumber(pctRaw)); + if (!isNaN(pctNum) && pctNum > 0) { + const grossNum = netNum / (1 - pctNum / 100); + grossAmount = grossNum.toFixed(4); + taxAmount = `-${(grossNum * (pctNum / 100)).toFixed(4)}`; } else { grossAmount = netAmount; } + } else { + grossAmount = netAmount; } // Dividend (gross amount — downstream engine expects gross) @@ -415,7 +408,7 @@ function parseAccountActivity(xlsx: typeof import("xlsx"), sheet: WorkSheet): Ca symbol: "CASH", description: "Interest Payment", isin: "", - currency: "USD", + currency: "USD", // eToro pays interest in USD regardless of interface language dateTime: tradeDate, settleDate: tradeDate, amount: `${amountNum}`,