diff --git a/src/cli/index.ts b/src/cli/index.ts index 78d88d2..16dbc28 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,6 +16,7 @@ import { Command } from "commander"; import Decimal from "decimal.js"; import { detectBroker, getBroker, brokerParsers } from "../parsers/index.js"; import { parseEtoroXlsx, detectEtoroXlsx } from "../parsers/etoro.js"; +import { parseRevolutXlsx, detectRevolutXlsx } from "../parsers/revolut.js"; import type { Statement } from "../types/broker.js"; import type { EcbRateMap } from "../types/ecb.js"; import { fetchEcbRates } from "../engine/ecb.js"; @@ -71,8 +72,15 @@ async function parseAndMerge( const brokerNames: string[] = []; for (const file of inputFiles) { - // Check for binary XLSX (eToro) first + // Check for binary XLSX (Revolut, eToro) first const buf = readFileSync(file); + if (await detectRevolutXlsx(buf)) { + const statement = await parseRevolutXlsx(buf); + mergeStatement(merged, statement); + brokerNames.push("Revolut"); + console.error(` [Revolut XLSX] ${file}: ${statement.trades.length} operaciones, ${statement.cashTransactions.length} transacciones`); + continue; + } if (detectEtoroXlsx(buf)) { const statement = await parseEtoroXlsx(buf); mergeStatement(merged, statement); diff --git a/src/engine/wash-sale.ts b/src/engine/wash-sale.ts index 19d2d50..47b0369 100644 --- a/src/engine/wash-sale.ts +++ b/src/engine/wash-sale.ts @@ -91,5 +91,6 @@ export function detectWashSales(disposals: FifoDisposal[], allTrades: Trade[]): function homogeneousKey(isin: string, symbol: string, assetCategory: string): string { if (assetCategory === "CRYPTO") return `CRYPTO:${symbol.toUpperCase()}`; if (isin) return isin; + if (symbol) return `${assetCategory}:${symbol.toUpperCase()}`; return ""; } diff --git a/src/parsers/revolut.ts b/src/parsers/revolut.ts index 48c498d..9810d46 100644 --- a/src/parsers/revolut.ts +++ b/src/parsers/revolut.ts @@ -1,25 +1,31 @@ /** * Revolut XLSX parser. * - * Parses Revolut's Trading Account Statement XLSX export into a normalized Statement. - * The workbook contains a single sheet with completed (round-trip) trades: + * Supports two Revolut Trading Account Statement formats: + * + * Format A — Closed-positions summary: * Date acquired | Date sold | Symbol | Quantity | Cost basis | Gross proceeds | * Gross PnL | Fees | Net PnL | Currency + * Each row = one round-trip → parser creates BUY + SELL legs. * - * Each row represents a closed position: the parser creates both a BUY (opening) - * and a SELL (closing) trade for the FIFO engine. + * Format B — Transaction log (what users actually get from the Revolut app): + * Date | Ticker | Type | Quantity | Price per share | Total Amount | Currency | FX Rate + * Each row = one event (BUY, SELL, CASH TOP-UP, CASH WITHDRAWAL, REWARD). + * Parser creates individual trades, extracts cash transactions, and infers + * open positions from unmatched buys. * * Revolut only offers XLSX or PDF exports (no CSV). * * Limitations: - * - No ISIN codes in Revolut exports — cross-broker FIFO matching uses symbol fallback. - * - No dividends/withholdings — Revolut's Trading Statement only has closed positions. - * Users need a separate Account Statement export for dividends (not yet supported). - * - No open positions — Modelo 720/D-6 cannot be generated from this data alone. + * - No ISIN codes in either format — cross-broker FIFO matching uses symbol fallback. + * - No dividends/withholdings in either format. Users need a separate Account Statement. + * - FX rates in format B are EUR/FCY. The parser stores fxRateToBase="1" and lets + * the FIFO engine fetch official ECB rates independently. */ +import Decimal from "decimal.js"; import type { BrokerParser, Statement } from "../types/broker.js"; -import type { Trade, AssetCategory } from "../types/ibkr.js"; +import type { Trade, CashTransaction, OpenPosition, AssetCategory } from "../types/ibkr.js"; import { findColumn, parseNumber } from "./csv-utils.js"; type WorkSheet = import("xlsx").WorkSheet; @@ -39,6 +45,25 @@ const GROSS_PNL_HEADERS = ["gross pnl", "pnl bruto"]; const FEES_HEADERS = ["fees", "comisiones", "tasas"]; const CURRENCY_HEADERS = ["currency", "divisa", "moneda"]; +// --------------------------------------------------------------------------- +// Transaction-log format column headers (the format users actually get from +// the Revolut app's Trading Account Statement export) +// --------------------------------------------------------------------------- + +const TXN_DATE_HEADERS = ["date", "fecha"]; +const TXN_TICKER_HEADERS = ["ticker"]; +const TXN_TYPE_HEADERS = ["type", "tipo"]; +const TXN_QUANTITY_HEADERS = ["quantity", "cantidad"]; +const TXN_PRICE_HEADERS = ["price per share", "precio por acción", "precio por accion"]; +const TXN_TOTAL_HEADERS = ["total amount", "importe total", "cantidad total"]; +const TXN_CURRENCY_HEADERS = ["currency", "divisa", "moneda"]; +const TXN_FX_RATE_HEADERS = ["fx rate", "tipo de cambio", "tasa de cambio"]; + +const TXN_TYPE_BUY = /^BUY\s*-\s*(MARKET|LIMIT)$/i; +const TXN_TYPE_SELL = /^SELL\s*-\s*(MARKET|LIMIT)$/i; +const TXN_TYPE_CASH_IN = /^CASH\s*TOP[- ]?UP$/i; +const TXN_TYPE_CASH_OUT = /^CASH\s*WITHDRAWAL$/i; + // --------------------------------------------------------------------------- // Crypto detection — Revolut mixes stocks and crypto in the same sheet. // Uses a known-crypto set plus a heuristic for unlisted tokens: symbols @@ -80,7 +105,10 @@ function detectAssetCategory(symbol: string): AssetCategory { export function parseRevolutDate(dateStr: string): string { const trimmed = dateStr.trim(); - // YYYY-MM-DD (primary Revolut format) + // ISO 8601 timestamp: 2025-10-20T06:00:02.425Z or 2025-10-19T00:02:32.169239Z + const isoTsMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})T/); + if (isoTsMatch) return `${isoTsMatch[1]}${isoTsMatch[2]}${isoTsMatch[3]}`; + // YYYY-MM-DD (primary Revolut closed-positions format) const isoMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/); if (isoMatch) return `${isoMatch[1]}${isoMatch[2]}${isoMatch[3]}`; // DD/MM/YYYY (EU format fallback) @@ -222,31 +250,284 @@ function parseTrades(xlsx: typeof import("xlsx"), sheet: WorkSheet): Trade[] { return trades; } +// --------------------------------------------------------------------------- +// Transaction-log amount parser +// --------------------------------------------------------------------------- + +/** + * Parse Revolut "CCY amount" strings like "EUR 15.61" or "USD -217.14". + * Returns { currency, amount } where amount is the raw numeric string. + */ +function parseCcyAmount(raw: string): { currency: string; amount: string } { + const trimmed = raw.trim(); + const match = trimmed.match(/^([A-Z]{3})\s+(-?[\d.,]+)$/); + if (match) return { currency: match[1]!, amount: parseNumber(match[2]!) }; + return { currency: "", amount: parseNumber(trimmed) }; +} + +// --------------------------------------------------------------------------- +// Transaction-log parser (the format users actually get from the Revolut app) +// --------------------------------------------------------------------------- + +interface TxnLogColumns { + date: number; + ticker: number; + type: number; + quantity: number; + price: number; + total: number; + currency: number; + fxRate: number; +} + +function findTxnLogColumns(headers: string[]): TxnLogColumns | null { + const date = findColumn(headers, TXN_DATE_HEADERS); + const ticker = findColumn(headers, TXN_TICKER_HEADERS); + const type = findColumn(headers, TXN_TYPE_HEADERS); + const quantity = findColumn(headers, TXN_QUANTITY_HEADERS); + const price = findColumn(headers, TXN_PRICE_HEADERS); + const total = findColumn(headers, TXN_TOTAL_HEADERS); + const currency = findColumn(headers, TXN_CURRENCY_HEADERS); + const fxRate = findColumn(headers, TXN_FX_RATE_HEADERS); + + if (date < 0 || type < 0 || total < 0 || currency < 0) return null; + return { date, ticker, type, quantity, price, total, currency, fxRate }; +} + +function parseTransactionLog( + xlsx: typeof import("xlsx"), + sheet: WorkSheet, +): { trades: Trade[]; cashTransactions: CashTransaction[]; openPositions: OpenPosition[] } { + const rows = sheetToRows(xlsx, sheet); + if (rows.length < 2) return { trades: [], cashTransactions: [], openPositions: [] }; + + const headers = rows[0]!; + const cols = findTxnLogColumns(headers); + if (!cols) return { trades: [], cashTransactions: [], openPositions: [] }; + + const trades: Trade[] = []; + const cashTransactions: CashTransaction[] = []; + + // Track net positions per symbol for open-position inference + const positions = new Map(); + + for (let i = 1; i < rows.length; i++) { + const row = rows[i]!; + if (!row.length || row.every((c) => !c.trim())) continue; + + const dateStr = (row[cols.date] ?? "").trim(); + if (!dateStr) continue; + const tradeDate = parseRevolutDate(dateStr); + + const type = (row[cols.type] ?? "").trim(); + const ticker = (row[cols.ticker] ?? "").trim(); + const currencyRaw = (row[cols.currency] ?? "").trim(); + const fxRateStr = (row[cols.fxRate] ?? "1").trim(); + const fxRate = parseFloat(parseNumber(fxRateStr)) || 1; + + const totalRaw = (row[cols.total] ?? "").trim(); + const { amount: totalAmountStr } = parseCcyAmount(totalRaw); + const totalAmount = parseFloat(totalAmountStr) || 0; + + // Cash flows: CASH TOP-UP, CASH WITHDRAWAL, REWARD + if (TXN_TYPE_CASH_IN.test(type) || TXN_TYPE_CASH_OUT.test(type)) { + cashTransactions.push({ + transactionID: `revolut-cash-${tradeDate}-${i}`, + accountId: "", + symbol: "", + description: type, + isin: "", + currency: currencyRaw, + dateTime: tradeDate, + settleDate: tradeDate, + amount: `${totalAmount}`, + fxRateToBase: currencyRaw === "EUR" ? "1" : `${1 / fxRate}`, + type: "Deposits/Withdrawals", + }); + continue; + } + + // Skip non-trade rows without a ticker (e.g. REWARD) + if (!ticker) continue; + + const quantityStr = (row[cols.quantity] ?? "0").trim(); + const priceStr = (row[cols.price] ?? "0").trim(); + + const { amount: qtyAmountStr } = parseCcyAmount(quantityStr); + const qty = Math.abs(parseFloat(qtyAmountStr) || 0); + if (qty === 0 || !isFinite(qty)) continue; + + const { amount: priceAmountStr } = parseCcyAmount(priceStr); + const pricePerShare = parseFloat(priceAmountStr) || 0; + + const assetCategory = detectAssetCategory(ticker); + const absTotalAmount = Math.abs(totalAmount); + + if (TXN_TYPE_BUY.test(type)) { + trades.push({ + tradeID: `revolut-buy-${tradeDate}-${ticker}-${i}`, + accountId: "", + symbol: ticker, + description: ticker, + isin: "", + assetCategory, + currency: currencyRaw, + tradeDate, + settlementDate: tradeDate, + quantity: `${qty}`, + tradePrice: `${pricePerShare}`, + tradeMoney: `-${absTotalAmount}`, + proceeds: "0", + cost: `-${absTotalAmount}`, + fifoPnlRealized: "0", + fxRateToBase: "1", + buySell: "BUY", + openCloseIndicator: "O", + exchange: "REVOLUT", + commissionCurrency: currencyRaw, + commission: "0", + taxes: "0", + multiplier: "1", + }); + + const pos = positions.get(ticker) ?? { + symbol: ticker, currency: currencyRaw, assetCategory, + netQty: new Decimal(0), totalCost: new Decimal(0), + }; + pos.netQty = pos.netQty.plus(qty); + pos.totalCost = pos.totalCost.plus(absTotalAmount); + positions.set(ticker, pos); + } else if (TXN_TYPE_SELL.test(type)) { + trades.push({ + tradeID: `revolut-sell-${tradeDate}-${ticker}-${i}`, + accountId: "", + symbol: ticker, + description: ticker, + isin: "", + assetCategory, + currency: currencyRaw, + tradeDate, + settlementDate: tradeDate, + quantity: `-${qty}`, + tradePrice: `${pricePerShare}`, + tradeMoney: `${absTotalAmount}`, + proceeds: `${absTotalAmount}`, + cost: "0", + fifoPnlRealized: "0", + fxRateToBase: "1", + buySell: "SELL", + openCloseIndicator: "C", + exchange: "REVOLUT", + commissionCurrency: currencyRaw, + commission: "0", + taxes: "0", + multiplier: "1", + }); + + const pos = positions.get(ticker); + if (pos && pos.netQty.greaterThan(0)) { + const sellQty = Decimal.min(qty, pos.netQty); + const costPerUnit = pos.totalCost.div(pos.netQty); + pos.totalCost = pos.totalCost.minus(costPerUnit.times(sellQty)); + pos.netQty = pos.netQty.minus(sellQty); + } + } + } + + // Build open positions from symbols with remaining shares + const openPositions: OpenPosition[] = []; + for (const pos of Array.from(positions.values())) { + if (pos.netQty.greaterThan(0.00000001)) { + const costPerUnit = pos.netQty.greaterThan(0) ? pos.totalCost.div(pos.netQty) : new Decimal(0); + openPositions.push({ + accountId: "", + symbol: pos.symbol, + description: pos.symbol, + isin: "", + currency: pos.currency, + assetCategory: pos.assetCategory, + quantity: pos.netQty.toString(), + costBasisMoney: pos.totalCost.toFixed(2), + costBasisPrice: costPerUnit.toFixed(8), + markPrice: "0", + positionValue: "0", + fifoPnlUnrealized: "0", + fxRateToBase: "1", + }); + } + } + + return { trades, cashTransactions, openPositions }; +} + +// --------------------------------------------------------------------------- +// Transaction-log detection +// --------------------------------------------------------------------------- + +function hasTxnLogHeaders(headers: string[]): boolean { + const lower = headers.map((h) => h.toLowerCase().trim()); + const hasDate = lower.some(h => TXN_DATE_HEADERS.includes(h)); + const hasTicker = lower.some(h => TXN_TICKER_HEADERS.includes(h)); + const hasType = lower.some(h => TXN_TYPE_HEADERS.includes(h)); + const hasTotal = lower.some(h => TXN_TOTAL_HEADERS.some(p => h.includes(p))); + const hasPrice = lower.some(h => TXN_PRICE_HEADERS.some(p => h.includes(p))); + return hasDate && hasTicker && hasType && hasTotal && hasPrice; +} + +function hasClosedPositionHeaders(headers: string[]): boolean { + const lower = headers.map((h) => h.toLowerCase().trim()); + const hasDateAcq = lower.some(h => DATE_ACQUIRED_HEADERS.some(p => h.includes(p))); + const hasCostOrProceeds = lower.some(h => + [...COST_BASIS_HEADERS, ...GROSS_PROCEEDS_HEADERS].some(p => h.includes(p)), + ); + return hasDateAcq && hasCostOrProceeds; +} + // --------------------------------------------------------------------------- // XLSX detection and parsing // --------------------------------------------------------------------------- /** * Parse Revolut XLSX workbook using the xlsx library. + * Supports two formats: + * 1. Closed-positions summary (Date acquired | Date sold | Symbol | ...) + * 2. Transaction log (Date | Ticker | Type | Quantity | Price per share | ...) */ export async function parseRevolutXlsx(data: Buffer | Uint8Array): Promise { const xlsx = await import("xlsx"); const wb = xlsx.read(data, { type: "buffer" }); - // Revolut uses a single sheet (typically "Sheet1") const sheet = wb.Sheets[wb.SheetNames[0]!]; - const trades = sheet ? parseTrades(xlsx, sheet) : []; + if (!sheet) { + return { + accountId: "", fromDate: "", toDate: "", period: "", + trades: [], cashTransactions: [], corporateActions: [], + openPositions: [], securitiesInfo: [], + }; + } + + // Detect format from first row + const headerRows = sheetToRows(xlsx, sheet); + const headers = headerRows[0] ?? []; + + if (hasTxnLogHeaders(headers)) { + const { trades, cashTransactions, openPositions } = parseTransactionLog(xlsx, sheet); + return { + accountId: "", fromDate: "", toDate: "", period: "", + trades, cashTransactions, corporateActions: [], + openPositions, securitiesInfo: [], + }; + } + // Fall back to closed-positions format + const trades = parseTrades(xlsx, sheet); return { - accountId: "", - fromDate: "", - toDate: "", - period: "", - trades, - cashTransactions: [], - corporateActions: [], - openPositions: [], - securitiesInfo: [], + accountId: "", fromDate: "", toDate: "", period: "", + trades, cashTransactions: [], corporateActions: [], + openPositions: [], securitiesInfo: [], }; } @@ -266,12 +547,8 @@ export async function detectRevolutXlsx(data: Buffer | Uint8Array): Promise(sheet, { header: 1, raw: false, defval: "" }); if (!rows.length) return false; - const headers = rows[0]!.map((h) => h.toLowerCase().trim()); - const hasDateAcq = headers.some(h => DATE_ACQUIRED_HEADERS.some(p => h.includes(p))); - const hasCostOrProceeds = headers.some(h => - [...COST_BASIS_HEADERS, ...GROSS_PROCEEDS_HEADERS].some(p => h.includes(p)), - ); - return hasDateAcq && hasCostOrProceeds; + const headers = rows[0]!; + return hasClosedPositionHeaders(headers) || hasTxnLogHeaders(headers); } catch { return false; } @@ -287,9 +564,15 @@ export const revolutParser: BrokerParser = { detect(input: string): boolean { const lower = input.toLowerCase(); - return (lower.includes("date acquired") || lower.includes("fecha de adquisición")) && + // Closed-positions format + const closedPos = (lower.includes("date acquired") || lower.includes("fecha de adquisición")) && (lower.includes("cost basis") || lower.includes("gross proceeds") || lower.includes("base de coste") || lower.includes("ingresos brutos")) && !lower.includes("closed position") && !lower.includes("posiciones cerradas"); + if (closedPos) return true; + // Transaction-log format: "Date" + "Ticker" + "Type" + "Total Amount" + // (must NOT match Trading 212 which also has "Type" but uses "No. of shares") + return lower.includes("ticker") && lower.includes("total amount") && + lower.includes("price per share") && !lower.includes("no. of shares"); }, parse(input: string): Statement { diff --git a/tests/engine/wash-sale.test.ts b/tests/engine/wash-sale.test.ts index 4195b6a..1916c19 100644 --- a/tests/engine/wash-sale.test.ts +++ b/tests/engine/wash-sale.test.ts @@ -94,6 +94,55 @@ describe("detectWashSales", () => { expect(result[0]!.washSaleBlocked).toBe(false); }); + it("should block loss for empty-ISIN trades using symbol fallback", () => { + const disposals = [makeDisposal({ + isin: "", + symbol: "AAPL", + sellDate: "2025-06-15", + gainLossEur: new Decimal(-100), + })]; + const trades = [ + { ...makeTrade("", "2025-06-15", "SELL"), symbol: "AAPL", isin: "" }, + { ...makeTrade("", "2025-07-01", "BUY"), symbol: "AAPL", isin: "" }, + ]; + + const result = detectWashSales(disposals, trades); + expect(result[0]!.washSaleBlocked).toBe(true); + }); + + it("should NOT match empty-ISIN trades with different symbols", () => { + const disposals = [makeDisposal({ + isin: "", + symbol: "AAPL", + sellDate: "2025-06-15", + gainLossEur: new Decimal(-100), + })]; + const trades = [ + { ...makeTrade("", "2025-06-15", "SELL"), symbol: "AAPL", isin: "" }, + { ...makeTrade("", "2025-07-01", "BUY"), symbol: "MSFT", isin: "" }, + ]; + + const result = detectWashSales(disposals, trades); + expect(result[0]!.washSaleBlocked).toBe(false); + }); + + it("should use 1-year window for CRYPTO asset category", () => { + const disposals = [makeDisposal({ + isin: "", + symbol: "BTC", + assetCategory: "CRYPTO", + sellDate: "2025-06-15", + gainLossEur: new Decimal(-500), + })]; + const trades = [ + { ...makeTrade("", "2025-06-15", "SELL"), symbol: "BTC", isin: "", assetCategory: "CRYPTO" as const }, + { ...makeTrade("", "2026-03-01", "BUY"), symbol: "BTC", isin: "", assetCategory: "CRYPTO" as const }, + ]; + + const result = detectWashSales(disposals, trades); + expect(result[0]!.washSaleBlocked).toBe(true); + }); + it("should NOT block loss for options (OPT assetCategory)", () => { const disposals = [makeDisposal({ sellDate: "2025-06-15", diff --git a/tests/fixtures/revolut-txnlog-sample.xlsx b/tests/fixtures/revolut-txnlog-sample.xlsx new file mode 100644 index 0000000..4e5a78a Binary files /dev/null and b/tests/fixtures/revolut-txnlog-sample.xlsx differ diff --git a/tests/parsers/revolut.test.ts b/tests/parsers/revolut.test.ts index 980181b..a4647bc 100644 --- a/tests/parsers/revolut.test.ts +++ b/tests/parsers/revolut.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { readFileSync } from "fs"; +import { readFileSync, existsSync } from "fs"; import * as XLSX from "xlsx"; import { revolutParser } from "../../src/parsers/revolut.js"; import { parseRevolutXlsx, detectRevolutXlsx, parseRevolutDate } from "../../src/parsers/revolut.js"; @@ -393,3 +393,449 @@ describe("revolutParser", () => { }); }); }); + +// =========================================================================== +// Transaction-log format tests +// =========================================================================== + +const TXN_LOG_HEADER = [ + "Date", "Ticker", "Type", "Quantity", "Price per share", + "Total Amount", "Currency", "FX Rate", +]; + +function buildTxnLogWorkbook(rows: (string | number)[][]): Uint8Array { + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([TXN_LOG_HEADER, ...rows]); + XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); + const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array; + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); +} + +const TXN_LOG_FIXTURE_PATH = new URL("../fixtures/revolut-txnlog-sample.xlsx", import.meta.url); +const TXN_LOG_XLSX = existsSync(TXN_LOG_FIXTURE_PATH) + ? readFileSync(TXN_LOG_FIXTURE_PATH) + : null; + +describe("revolutParser — transaction-log format", () => { + // ----------------------------------------------------------------------- + // Detection + // ----------------------------------------------------------------------- + describe("detectRevolutXlsx (transaction-log)", () => { + it("should detect a transaction-log XLSX", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:02.425Z", "AAPL", "BUY - MARKET", "1", "USD 180", "USD 180", "USD", "1.08"], + ]); + expect(await detectRevolutXlsx(data)).toBe(true); + }); + + it("should detect the transaction-log fixture", async () => { + if (!TXN_LOG_XLSX) return; + expect(await detectRevolutXlsx(TXN_LOG_XLSX)).toBe(true); + }); + + it("should return false for corrupted XLSX (valid ZIP magic but bad content)", async () => { + // PK\x03\x04 magic bytes followed by garbage + const corrupted = Buffer.from([0x50, 0x4B, 0x03, 0x04, 0xFF, 0xFF, 0xFF, 0xFF]); + expect(await detectRevolutXlsx(corrupted)).toBe(false); + }); + + it("should not false-positive on a non-Revolut XLSX with 'Date' header", async () => { + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([ + ["Date", "Action", "No. of shares", "Price / share"], + ["2025-01-01", "Buy", "10", "100"], + ]); + XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); + const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array; + const data = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + expect(await detectRevolutXlsx(data)).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // Text-based detect (transaction-log) + // ----------------------------------------------------------------------- + describe("detect (text, transaction-log)", () => { + it("should detect transaction-log headers", () => { + const header = "Date\tTicker\tType\tQuantity\tPrice per share\tTotal Amount\tCurrency\tFX Rate"; + expect(revolutParser.detect(header)).toBe(true); + }); + + it("should not false-positive on Trading 212 headers", () => { + const header = "Action\tTime\tISIN\tTicker\tName\tNo. of shares\tPrice / share\tCurrency\tTotal Amount"; + expect(revolutParser.detect(header)).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // parseRevolutDate — ISO 8601 timestamps + // ----------------------------------------------------------------------- + describe("parseRevolutDate (ISO 8601 timestamps)", () => { + it("should parse ISO 8601 with milliseconds", () => { + expect(parseRevolutDate("2025-10-20T06:00:02.425Z")).toBe("20251020"); + }); + + it("should parse ISO 8601 with microseconds", () => { + expect(parseRevolutDate("2025-10-19T00:02:32.169239Z")).toBe("20251019"); + }); + + it("should parse ISO 8601 without fractional seconds", () => { + expect(parseRevolutDate("2025-12-30T00:08:20Z")).toBe("20251230"); + }); + }); + + // ----------------------------------------------------------------------- + // Full parsing — programmatic fixtures + // ----------------------------------------------------------------------- + describe("parseRevolutXlsx (transaction-log, programmatic)", () => { + it("should parse BUY - MARKET trades", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:02.425Z", "AAPL", "BUY - MARKET", "3.19", "USD 156.10", "USD 498", "USD", "1.08"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(1); + const buy = stmt.trades[0]!; + expect(buy.buySell).toBe("BUY"); + expect(buy.symbol).toBe("AAPL"); + expect(parseFloat(buy.quantity)).toBeCloseTo(3.19, 2); + expect(parseFloat(buy.tradePrice)).toBeCloseTo(156.1, 1); + expect(buy.currency).toBe("USD"); + expect(buy.tradeDate).toBe("20251020"); + expect(buy.exchange).toBe("REVOLUT"); + expect(buy.openCloseIndicator).toBe("O"); + expect(buy.assetCategory).toBe("STK"); + }); + + it("should parse SELL - MARKET trades", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "5", "USD 150", "USD 750", "USD", "1.08"], + ["2025-11-10T15:33:15.722Z", "AAPL", "SELL - MARKET", "5", "USD 177.75", "USD 888.75", "USD", "1.158"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(2); + const sell = stmt.trades[1]!; + expect(sell.buySell).toBe("SELL"); + expect(sell.symbol).toBe("AAPL"); + expect(parseFloat(sell.quantity)).toBeLessThan(0); + expect(sell.proceeds).toBe("888.75"); + expect(sell.openCloseIndicator).toBe("C"); + expect(sell.tradeDate).toBe("20251110"); + }); + + it("should parse SELL - LIMIT trades", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "GZMO", "BUY - MARKET", "5", "USD 30", "USD 150", "USD", "1.08"], + ["2025-11-10T20:57:52.676Z", "GZMO", "SELL - LIMIT", "5", "USD 30", "USD 149.99", "USD", "1.159"], + ]); + const stmt = await parseRevolutXlsx(data); + const sell = stmt.trades[1]!; + expect(sell.buySell).toBe("SELL"); + expect(sell.proceeds).toBe("149.99"); + }); + + it("should parse EUR trades with FX Rate 1", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:02.425Z", "SAP", "BUY - MARKET", "3.19", "EUR 15.61", "EUR 50", "EUR", "1"], + ]); + const stmt = await parseRevolutXlsx(data); + const buy = stmt.trades[0]!; + expect(buy.currency).toBe("EUR"); + expect(parseFloat(buy.tradePrice)).toBeCloseTo(15.61, 2); + }); + + it("should extract CASH TOP-UP as Deposits/Withdrawals", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-19T00:02:32.169239Z", "", "CASH TOP-UP", "", "", "EUR 250", "EUR", "1"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(0); + expect(stmt.cashTransactions.length).toBe(1); + const cash = stmt.cashTransactions[0]!; + expect(cash.type).toBe("Deposits/Withdrawals"); + expect(cash.description).toBe("CASH TOP-UP"); + expect(parseFloat(cash.amount)).toBe(250); + expect(cash.currency).toBe("EUR"); + }); + + it("should extract CASH WITHDRAWAL as Deposits/Withdrawals with negative amount", async () => { + const data = buildTxnLogWorkbook([ + ["2025-12-30T00:08:20.679435Z", "", "CASH WITHDRAWAL", "", "", "EUR -160.80", "EUR", "1"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.cashTransactions.length).toBe(1); + const cash = stmt.cashTransactions[0]!; + expect(cash.type).toBe("Deposits/Withdrawals"); + expect(parseFloat(cash.amount)).toBe(-160.8); + }); + + it("should skip REWARD rows (no ticker)", async () => { + const data = buildTxnLogWorkbook([ + ["2025-12-19T17:16:01.651593Z", "", "REWARD", "", "", "USD 0.75", "USD", "1.1743"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(0); + expect(stmt.cashTransactions.length).toBe(0); + }); + + it("should infer open positions from unmatched buys", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "MELI", "BUY - MARKET", "0.07138129", "USD 2085", "USD 148.83", "USD", "1.082"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(1); + expect(stmt.openPositions.length).toBe(1); + const pos = stmt.openPositions[0]!; + expect(pos.symbol).toBe("MELI"); + expect(parseFloat(pos.quantity)).toBeCloseTo(0.07138129, 6); + expect(parseFloat(pos.costBasisMoney)).toBeCloseTo(148.83, 2); + expect(pos.currency).toBe("USD"); + expect(pos.assetCategory).toBe("STK"); + }); + + it("should reduce open position when partially sold", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "10", "USD 150", "USD 1500", "USD", "1.08"], + ["2025-11-10T15:00:00Z", "AAPL", "SELL - MARKET", "6", "USD 160", "USD 960", "USD", "1.15"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(2); + expect(stmt.openPositions.length).toBe(1); + const pos = stmt.openPositions[0]!; + expect(pos.symbol).toBe("AAPL"); + expect(parseFloat(pos.quantity)).toBeCloseTo(4, 0); + }); + + it("should not create open position when fully sold", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "10", "USD 150", "USD 1500", "USD", "1.08"], + ["2025-11-10T15:00:00Z", "AAPL", "SELL - MARKET", "10", "USD 160", "USD 1600", "USD", "1.15"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.openPositions.length).toBe(0); + }); + + it("should handle fractional shares (up to 8 decimals)", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "BRK.B", "BUY - MARKET", "1.43231884", "USD 69", "USD 98.83", "USD", "1.08"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(parseFloat(stmt.trades[0]!.quantity)).toBeCloseTo(1.43231884, 6); + }); + + it("should detect crypto symbols (BTC)", async () => { + const data = buildTxnLogWorkbook([ + ["2025-12-15T12:00:00Z", "BTC", "BUY - MARKET", "0.001", "USD 42000", "USD 42", "USD", "1.05"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades[0]!.assetCategory).toBe("CRYPTO"); + }); + + it("should handle multiple buys of same symbol", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "ACME", "BUY - MARKET", "3", "EUR 15.61", "EUR 46.83", "EUR", "1"], + ["2025-11-07T10:00:00Z", "ACME", "BUY - MARKET", "7", "EUR 14.35", "EUR 100.45", "EUR", "1"], + ["2025-12-22T16:56:00Z", "ACME", "SELL - LIMIT", "10", "EUR 16.08", "EUR 160.80", "EUR", "1"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(3); + expect(stmt.openPositions.length).toBe(0); + }); + + it("should skip blank rows", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "5", "USD 150", "USD 750", "USD", "1.08"], + ["", "", "", "", "", "", "", ""], + ["2025-11-10T15:00:00Z", "AAPL", "SELL - MARKET", "5", "USD 160", "USD 800", "USD", "1.15"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(2); + }); + + it("should skip rows with zero quantity", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "0", "USD 150", "USD 0", "USD", "1.08"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(0); + }); + + it("should return empty arrays for empty sheet", async () => { + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([TXN_LOG_HEADER]); + XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); + const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array; + const data = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(0); + expect(stmt.cashTransactions.length).toBe(0); + expect(stmt.openPositions.length).toBe(0); + }); + + it("should set fxRateToBase to '1' (ECB engine handles FX)", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "5", "USD 150", "USD 750", "USD", "1.08"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades[0]!.fxRateToBase).toBe("1"); + }); + + it("should set isin to empty string", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "5", "USD 150", "USD 750", "USD", "1.08"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades[0]!.isin).toBe(""); + }); + + it("should set fxRateToBase on cash transactions for non-EUR currencies", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-19T00:00:00Z", "", "CASH TOP-UP", "", "", "USD 200", "USD", "1.08"], + ]); + const stmt = await parseRevolutXlsx(data); + const cash = stmt.cashTransactions[0]!; + expect(parseFloat(cash.fxRateToBase)).toBeCloseTo(1 / 1.08, 4); + }); + + it("should set fxRateToBase to '1' on EUR cash transactions", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-19T00:00:00Z", "", "CASH TOP-UP", "", "", "EUR 250", "EUR", "1"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.cashTransactions[0]!.fxRateToBase).toBe("1"); + }); + + it("should parse BUY - LIMIT trades", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "MSFT", "BUY - LIMIT", "2", "USD 400", "USD 800", "USD", "1.08"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(1); + expect(stmt.trades[0]!.buySell).toBe("BUY"); + expect(stmt.trades[0]!.symbol).toBe("MSFT"); + expect(parseFloat(stmt.trades[0]!.quantity)).toBe(2); + }); + + it("should handle negative quantity by taking absolute value", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "-5", "USD 150", "USD 750", "USD", "1.08"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(1); + expect(parseFloat(stmt.trades[0]!.quantity)).toBe(5); + }); + + it("should cap sell at net position (sell exceeding buys)", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "3", "USD 150", "USD 450", "USD", "1.08"], + ["2025-11-10T15:00:00Z", "AAPL", "SELL - MARKET", "10", "USD 160", "USD 1600", "USD", "1.15"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(2); + // Position should not go negative — open positions should be empty (capped at 0) + expect(stmt.openPositions.length).toBe(0); + }); + + it("should skip rows with Infinity quantity", async () => { + const data = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "Infinity", "USD 150", "USD 750", "USD", "1.08"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(0); + }); + }); + + // ----------------------------------------------------------------------- + // Real fixture (transaction-log) + // ----------------------------------------------------------------------- + describe("parseRevolutXlsx (transaction-log fixture)", () => { + it("should parse the fixture with correct counts", async () => { + if (!TXN_LOG_XLSX) return; + const stmt = await parseRevolutXlsx(TXN_LOG_XLSX); + // 5 BUY + 4 SELL = 9 trade rows in the fixture (some symbols bought multiple times) + const buys = stmt.trades.filter(t => t.buySell === "BUY"); + const sells = stmt.trades.filter(t => t.buySell === "SELL"); + expect(buys.length).toBeGreaterThan(0); + expect(sells.length).toBeGreaterThan(0); + expect(stmt.trades.length).toBe(buys.length + sells.length); + }); + + it("should extract cash transactions from fixture", async () => { + if (!TXN_LOG_XLSX) return; + const stmt = await parseRevolutXlsx(TXN_LOG_XLSX); + expect(stmt.cashTransactions.length).toBeGreaterThan(0); + const topups = stmt.cashTransactions.filter(c => c.description === "CASH TOP-UP"); + const withdrawals = stmt.cashTransactions.filter(c => c.description === "CASH WITHDRAWAL"); + expect(topups.length).toBeGreaterThan(0); + expect(withdrawals.length).toBeGreaterThan(0); + }); + + it("should infer open positions from fixture", async () => { + if (!TXN_LOG_XLSX) return; + const stmt = await parseRevolutXlsx(TXN_LOG_XLSX); + // BTC is bought but never sold in the fixture + const btcPos = stmt.openPositions.find(p => p.symbol === "BTC"); + expect(btcPos).toBeDefined(); + expect(btcPos!.assetCategory).toBe("CRYPTO"); + }); + + it("should not have ACME as open position (fully sold in fixture)", async () => { + if (!TXN_LOG_XLSX) return; + const stmt = await parseRevolutXlsx(TXN_LOG_XLSX); + const acmePos = stmt.openPositions.find(p => p.symbol === "ACME"); + expect(acmePos).toBeUndefined(); + }); + }); + + // ----------------------------------------------------------------------- + // Format coexistence: both formats still detected and parsed + // ----------------------------------------------------------------------- + describe("format coexistence", () => { + it("should still detect closed-positions format", async () => { + const data = buildRevolutWorkbook([ + ["2020-01-01", "2020-02-10", "AAPL", "5", "1000", "1100", "100", "0", "100", "USD"], + ]); + expect(await detectRevolutXlsx(data)).toBe(true); + }); + + it("should still parse closed-positions format correctly", async () => { + const data = buildRevolutWorkbook([ + ["2020-01-01", "2020-02-10", "AAPL", "5", "1000", "1100", "100", "0", "100", "USD"], + ]); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(2); + expect(stmt.trades[0]!.buySell).toBe("BUY"); + expect(stmt.trades[1]!.buySell).toBe("SELL"); + }); + + it("should return empty statement for sheet with no content", async () => { + // Build a workbook with a sheet that has absolutely no cells + const xlsx = await import("xlsx"); + const wb = xlsx.utils.book_new(); + const ws: import("xlsx").WorkSheet = {}; + xlsx.utils.book_append_sheet(wb, ws, "Empty"); + const buf = xlsx.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array; + const data = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + const stmt = await parseRevolutXlsx(data); + expect(stmt.trades.length).toBe(0); + expect(stmt.cashTransactions.length).toBe(0); + expect(stmt.openPositions.length).toBe(0); + }); + + it("should route transaction-log to new parser and closed-positions to old", async () => { + const txnData = buildTxnLogWorkbook([ + ["2025-10-20T06:00:00Z", "AAPL", "BUY - MARKET", "5", "USD 150", "USD 750", "USD", "1.08"], + ]); + const closedData = buildRevolutWorkbook([ + ["2020-01-01", "2020-02-10", "AAPL", "5", "1000", "1100", "100", "0", "100", "USD"], + ]); + const txnStmt = await parseRevolutXlsx(txnData); + const closedStmt = await parseRevolutXlsx(closedData); + // Transaction-log has cash transaction support + expect(txnStmt.trades.length).toBe(1); + expect(txnStmt.trades[0]!.tradeID).toMatch(/^revolut-buy-/); + // Closed-positions has paired buy/sell legs + expect(closedStmt.trades.length).toBe(2); + expect(closedStmt.trades[0]!.tradeID).toMatch(/^revolut-open-/); + }); + }); +});