From f1f4d36997cf7f71abf1fdee895605095132612e Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 3 May 2026 11:35:58 +0200 Subject: [PATCH 1/3] feat(revolut): support transaction-log format (BUY/SELL individual trades) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revolut's Trading Account Statement has two XLSX formats: closed-positions summary (existing) and transaction log (Date/Ticker/Type/Quantity/Price per share/Total Amount/Currency/FX Rate). The transaction log is what users actually get from the Revolut app, and the parser was rejecting it entirely. - Add transaction-log detection in detectRevolutXlsx (both formats now detected) - Parse BUY-MARKET, BUY-LIMIT, SELL-MARKET, SELL-LIMIT as individual trades - Parse "CCY amount" strings (e.g. "EUR 15.61", "USD -217.14") - Parse ISO 8601 timestamps with ms/μs precision - Extract CASH TOP-UP / CASH WITHDRAWAL as cashTransactions - Infer open positions from unmatched buys (enables Modelo 720/D-6) - Update text-based detect() for transaction-log headers - 35 new tests (78 total), anonymized fixture from real user file --- src/parsers/revolut.ts | 335 +++++++++++++++++-- tests/fixtures/revolut-txnlog-sample.xlsx | Bin 0 -> 19911 bytes tests/parsers/revolut.test.ts | 389 +++++++++++++++++++++- 3 files changed, 695 insertions(+), 29 deletions(-) create mode 100644 tests/fixtures/revolut-txnlog-sample.xlsx diff --git a/src/parsers/revolut.ts b/src/parsers/revolut.ts index 48c498d..32f5ab5 100644 --- a/src/parsers/revolut.ts +++ b/src/parsers/revolut.ts @@ -1,25 +1,30 @@ /** * 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 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 +44,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 +104,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 +249,281 @@ 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 = parseFloat(qtyAmountStr) || 0; + if (qty === 0) 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: 0, totalCost: 0, + }; + pos.netQty += qty; + pos.totalCost += 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) { + const costPerUnit = pos.netQty > 0 ? pos.totalCost / pos.netQty : 0; + pos.totalCost -= costPerUnit * qty; + pos.netQty -= qty; + } + } + } + + // Build open positions from symbols with remaining shares + const openPositions: OpenPosition[] = []; + for (const pos of Array.from(positions.values())) { + if (pos.netQty > 0.00000001) { + const costPerUnit = pos.netQty > 0 ? pos.totalCost / pos.netQty : 0; + openPositions.push({ + accountId: "", + symbol: pos.symbol, + description: pos.symbol, + isin: "", + currency: pos.currency, + assetCategory: pos.assetCategory, + quantity: `${pos.netQty}`, + 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))); + return hasDate && hasTicker && hasType && hasTotal; +} + +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 +543,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 +560,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/fixtures/revolut-txnlog-sample.xlsx b/tests/fixtures/revolut-txnlog-sample.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..4e5a78ad702dafc74afb51e7457f10ec97a72f13 GIT binary patch literal 19911 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 literal 0 HcmV?d00001 diff --git a/tests/parsers/revolut.test.ts b/tests/parsers/revolut.test.ts index 980181b..f4477cb 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,390 @@ 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 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"); + }); + }); + + // ----------------------------------------------------------------------- + // 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 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-/); + }); + }); +}); From 1e147d17fbe442b2ddc6163c1b5a342e4a6b525a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 3 May 2026 11:51:23 +0200 Subject: [PATCH 2/3] fix(revolut): harden parser edge cases and wash-sale symbol fallback - Add Math.abs + isFinite guard on transaction-log quantity to prevent negative and Infinity propagation - Cap sell quantity at net position to prevent negative position state - Tighten hasTxnLogHeaders to require "price per share" column - Add symbol-based fallback in wash-sale homogeneousKey() for trades without ISINs (Revolut, Lightyear, etc.) - Add Revolut XLSX detection in CLI before eToro - Add 9 edge case tests (BUY-LIMIT, negative qty, sell overflow, Infinity, corrupted XLSX, empty sheet, wash-sale symbol fallback) --- src/cli/index.ts | 10 +++++- src/engine/wash-sale.ts | 1 + src/parsers/revolut.ts | 16 +++++---- tests/engine/wash-sale.test.ts | 49 ++++++++++++++++++++++++++++ tests/parsers/revolut.test.ts | 59 ++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 8 deletions(-) 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 32f5ab5..9d29635 100644 --- a/src/parsers/revolut.ts +++ b/src/parsers/revolut.ts @@ -356,8 +356,8 @@ function parseTransactionLog( const priceStr = (row[cols.price] ?? "0").trim(); const { amount: qtyAmountStr } = parseCcyAmount(quantityStr); - const qty = parseFloat(qtyAmountStr) || 0; - if (qty === 0) continue; + const qty = Math.abs(parseFloat(qtyAmountStr) || 0); + if (qty === 0 || !isFinite(qty)) continue; const { amount: priceAmountStr } = parseCcyAmount(priceStr); const pricePerShare = parseFloat(priceAmountStr) || 0; @@ -426,10 +426,11 @@ function parseTransactionLog( }); const pos = positions.get(ticker); - if (pos) { - const costPerUnit = pos.netQty > 0 ? pos.totalCost / pos.netQty : 0; - pos.totalCost -= costPerUnit * qty; - pos.netQty -= qty; + if (pos && pos.netQty > 0) { + const sellQty = Math.min(qty, pos.netQty); + const costPerUnit = pos.totalCost / pos.netQty; + pos.totalCost -= costPerUnit * sellQty; + pos.netQty -= sellQty; } } } @@ -470,7 +471,8 @@ function hasTxnLogHeaders(headers: string[]): boolean { 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))); - return hasDate && hasTicker && hasType && hasTotal; + const hasPrice = lower.some(h => TXN_PRICE_HEADERS.some(p => h.includes(p))); + return hasDate && hasTicker && hasType && hasTotal && hasPrice; } function hasClosedPositionHeaders(headers: string[]): boolean { 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/parsers/revolut.test.ts b/tests/parsers/revolut.test.ts index f4477cb..a4647bc 100644 --- a/tests/parsers/revolut.test.ts +++ b/tests/parsers/revolut.test.ts @@ -433,6 +433,12 @@ describe("revolutParser — transaction-log format", () => { 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([ @@ -697,6 +703,45 @@ describe("revolutParser — transaction-log format", () => { 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); + }); }); // ----------------------------------------------------------------------- @@ -762,6 +807,20 @@ describe("revolutParser — transaction-log format", () => { 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"], From 61a3c135a71bd81dcf78e3a1a1092988ace29c21 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 3 May 2026 12:39:04 +0200 Subject: [PATCH 3/3] refactor(revolut): use Decimal for position tracking arithmetic Address CodeRabbit nitpick: replace Number with Decimal.js for netQty, totalCost, and costPerUnit in open-position inference to prevent floating-point drift per project financial precision rules. --- src/parsers/revolut.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/parsers/revolut.ts b/src/parsers/revolut.ts index 9d29635..9810d46 100644 --- a/src/parsers/revolut.ts +++ b/src/parsers/revolut.ts @@ -23,6 +23,7 @@ * the FIFO engine fetch official ECB rates independently. */ +import Decimal from "decimal.js"; import type { BrokerParser, Statement } from "../types/broker.js"; import type { Trade, CashTransaction, OpenPosition, AssetCategory } from "../types/ibkr.js"; import { findColumn, parseNumber } from "./csv-utils.js"; @@ -310,7 +311,7 @@ function parseTransactionLog( // Track net positions per symbol for open-position inference const positions = new Map(); for (let i = 1; i < rows.length; i++) { @@ -393,10 +394,11 @@ function parseTransactionLog( }); const pos = positions.get(ticker) ?? { - symbol: ticker, currency: currencyRaw, assetCategory, netQty: 0, totalCost: 0, + symbol: ticker, currency: currencyRaw, assetCategory, + netQty: new Decimal(0), totalCost: new Decimal(0), }; - pos.netQty += qty; - pos.totalCost += absTotalAmount; + 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({ @@ -426,11 +428,11 @@ function parseTransactionLog( }); const pos = positions.get(ticker); - if (pos && pos.netQty > 0) { - const sellQty = Math.min(qty, pos.netQty); - const costPerUnit = pos.totalCost / pos.netQty; - pos.totalCost -= costPerUnit * sellQty; - pos.netQty -= sellQty; + 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); } } } @@ -438,8 +440,8 @@ function parseTransactionLog( // Build open positions from symbols with remaining shares const openPositions: OpenPosition[] = []; for (const pos of Array.from(positions.values())) { - if (pos.netQty > 0.00000001) { - const costPerUnit = pos.netQty > 0 ? pos.totalCost / pos.netQty : 0; + 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, @@ -447,7 +449,7 @@ function parseTransactionLog( isin: "", currency: pos.currency, assetCategory: pos.assetCategory, - quantity: `${pos.netQty}`, + quantity: pos.netQty.toString(), costBasisMoney: pos.totalCost.toFixed(2), costBasisPrice: costPerUnit.toFixed(8), markPrice: "0",