diff --git a/src/parsers/lightyear.ts b/src/parsers/lightyear.ts index ad8f4bf..f21bea3 100644 --- a/src/parsers/lightyear.ts +++ b/src/parsers/lightyear.ts @@ -103,7 +103,7 @@ function convertLightyearDate(dateStr: string): string { // Transaction type classification // --------------------------------------------------------------------------- -const SKIP_TYPES = new Set(["deposit", "withdrawal", "conversion"]); +const SKIP_TYPES = new Set(["deposit", "withdrawal"]); const DIVIDEND_TYPES = new Set(["dividend", "distribution"]); const INCOME_TYPES = new Set(["interest", "reward"]); @@ -207,6 +207,44 @@ function parseLightyearCsv(lines: string[]): Statement { continue; } + // FX conversions → CASH trades for the FX FIFO engine (Art. 37.1.l LIRPF) + // Lightyear emits a pair: one leg per currency (e.g. USD +64.50, EUR -59.24). + // Positive gross = acquiring that currency (BUY), negative = spending it (SELL). + if (txType === "conversion") { + const grossDec = new Decimal(grossAmount); + if (grossDec.isZero() || currency === "EUR") continue; + + const absDec = grossDec.abs(); + const isFxBuy = grossDec.greaterThan(0); + + trades.push({ + tradeID: `lightyear-fx-${reference || `${tradeDate}-${currency}-${i}`}`, + accountId: "", + symbol: `${currency}.EUR`, + description: `FX Conversion ${isFxBuy ? "EUR→" : "→EUR "}${currency}`, + isin: "", + assetCategory: "CASH", + currency, + tradeDate, + settlementDate: tradeDate, + quantity: isFxBuy ? absDec.toString() : absDec.neg().toString(), + tradePrice: "1", + tradeMoney: isFxBuy ? absDec.neg().toString() : absDec.toString(), + proceeds: isFxBuy ? "0" : absDec.toString(), + cost: isFxBuy ? absDec.neg().toString() : "0", + fifoPnlRealized: "0", + fxRateToBase: "1", + buySell: isFxBuy ? "BUY" : "SELL", + openCloseIndicator: isFxBuy ? "O" : "C", + exchange: "LIGHTYEAR", + commissionCurrency: currency, + commission: "0", + taxes: "0", + multiplier: "1", + }); + continue; + } + // Buy / Sell trades const isSell = txType === "sell"; const isBuy = txType === "buy"; diff --git a/src/parsers/trading212.ts b/src/parsers/trading212.ts index daaba96..fb87f96 100644 --- a/src/parsers/trading212.ts +++ b/src/parsers/trading212.ts @@ -115,6 +115,26 @@ function isSkippedAction(action: string): boolean { return /deposit|withdrawal|currency conversion|card debit|spending cashback/i.test(action); } +// --------------------------------------------------------------------------- +// Fractional currency normalization +// Trading 212 reports GBX (pence), ZAc (South African cents), ILA (Israeli +// agorot) as-is. ECB only publishes GBP/ZAR/ILS so we normalize and ÷100. +// --------------------------------------------------------------------------- + +const FRACTIONAL_CURRENCIES: Record = { + GBX: { base: "GBP", divisor: new Decimal(100) }, + ZAC: { base: "ZAR", divisor: new Decimal(100) }, + ILA: { base: "ILS", divisor: new Decimal(100) }, +}; + +function normalizeCurrency(code: string): { currency: string; divisor: Decimal } { + const entry = FRACTIONAL_CURRENCIES[code.toUpperCase()]; + if (entry && code.toUpperCase() !== entry.base) { + return { currency: entry.base, divisor: entry.divisor }; + } + return { currency: code, divisor: new Decimal(1) }; +} + // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- @@ -143,10 +163,13 @@ function parseTrading212Csv(lines: string[]): Statement { const name = (fields[cols.name] ?? "").trim(); const sharesStr = parseNumber(fields[cols.shares] ?? "0"); const priceStr = parseNumber(fields[cols.pricePerShare] ?? "0"); - const currency = (fields[cols.priceCurrency] ?? "").trim() || "EUR"; + const rawCurrency = (fields[cols.priceCurrency] ?? "").trim() || "EUR"; + const { currency: priceCurrency, divisor: priceDivisor } = normalizeCurrency(rawCurrency); + const currency = priceCurrency; // Currency (Total) is authoritative for cash transaction amounts: in EUR-primary accounts // the Total column holds the credited EUR amount, not the instrument's native currency. - const cashCurrency = (fields[cols.totalCurrency] ?? "").trim() || currency; + const rawCashCurrency = (fields[cols.totalCurrency] ?? "").trim() || rawCurrency; + const { currency: cashCurrency, divisor: cashDivisor } = normalizeCurrency(rawCashCurrency); const totalStr = parseNumber(fields[cols.total] ?? "0"); const txId = (fields[cols.id] ?? "").trim(); @@ -160,7 +183,7 @@ function parseTrading212Csv(lines: string[]): Statement { // Withholding tax on dividend — must be checked before the generic dividend branch if (isDividendTaxAction(action)) { if (!ticker) continue; - const amountDec = new Decimal(totalStr).abs(); + const amountDec = new Decimal(totalStr).abs().div(cashDivisor); if (amountDec.isZero()) continue; cashTransactions.push({ @@ -182,7 +205,7 @@ function parseTrading212Csv(lines: string[]): Statement { // Dividends if (isDividendAction(action)) { if (!ticker) continue; - const amountDec = new Decimal(totalStr).abs(); + const amountDec = new Decimal(totalStr).abs().div(cashDivisor); if (amountDec.isZero()) continue; cashTransactions.push({ @@ -203,7 +226,7 @@ function parseTrading212Csv(lines: string[]): Statement { // Interest on cash if (isInterestAction(action)) { - const amountDec = new Decimal(totalStr).abs(); + const amountDec = new Decimal(totalStr).abs().div(cashDivisor); if (amountDec.isZero()) continue; cashTransactions.push({ @@ -231,8 +254,8 @@ function parseTrading212Csv(lines: string[]): Statement { const qtyDec = new Decimal(sharesStr).abs(); if (qtyDec.isZero()) continue; - const priceDec = new Decimal(priceStr); - const totalDec = new Decimal(totalStr).abs(); + const priceDec = new Decimal(priceStr).div(priceDivisor); + const totalDec = new Decimal(totalStr).abs().div(cashDivisor); if (priceDec.isZero() && !totalDec.isZero() && cashCurrency !== currency) { continue; } diff --git a/tests/parsers/lightyear.test.ts b/tests/parsers/lightyear.test.ts index e2fd66d..9af2733 100644 --- a/tests/parsers/lightyear.test.ts +++ b/tests/parsers/lightyear.test.ts @@ -289,10 +289,14 @@ describe("lightyearParser", () => { expect(allIds.some((id) => id.includes("withdrawal"))).toBe(false); }); - it("should skip Conversion rows", () => { + it("should parse Conversion rows as CASH trades (FX FIFO)", () => { const result = lightyearParser.parse(LIGHTYEAR_CSV); - const allIds = [...result.trades.map((t) => t.tradeID), ...result.cashTransactions.map((c) => c.transactionID)]; - expect(allIds.some((id) => id.includes("conversion"))).toBe(false); + const fxTrades = result.trades.filter((t) => t.assetCategory === "CASH"); + // Only the non-EUR leg (USD +64.50) becomes a CASH trade; EUR leg is skipped + expect(fxTrades).toHaveLength(1); + expect(fxTrades[0]!.currency).toBe("USD"); + expect(fxTrades[0]!.buySell).toBe("BUY"); + expect(fxTrades[0]!.quantity).toBe("64.5"); }); }); @@ -303,8 +307,8 @@ describe("lightyearParser", () => { describe("overall counts", () => { it("should produce correct number of trades", () => { const result = lightyearParser.parse(LIGHTYEAR_CSV); - // AAPL Buy, AAPL Sell, BRICEKSP Buy, ICSUSSDP Buy, ICSUSSDP Sell = 5 - expect(result.trades).toHaveLength(5); + // AAPL Buy, AAPL Sell, BRICEKSP Buy, ICSUSSDP Buy, ICSUSSDP Sell + USD Conversion = 6 + expect(result.trades).toHaveLength(6); }); it("should produce correct number of cash transactions", () => { @@ -360,6 +364,50 @@ describe("lightyearParser", () => { }); }); + // ------------------------------------------------------------------------- + // FX Conversions (Art. 37.1.l LIRPF) + // ------------------------------------------------------------------------- + + describe("FX conversions", () => { + it("should emit CASH trade for non-EUR conversion leg", () => { + const result = lightyearParser.parse(LIGHTYEAR_CSV); + const fxTrades = result.trades.filter((t) => t.assetCategory === "CASH"); + expect(fxTrades).toHaveLength(1); + const fx = fxTrades[0]!; + expect(fx.currency).toBe("USD"); + expect(fx.buySell).toBe("BUY"); + expect(fx.quantity).toBe("64.5"); + expect(fx.symbol).toBe("USD.EUR"); + }); + + it("should skip EUR leg of conversion pair", () => { + const result = lightyearParser.parse(LIGHTYEAR_CSV); + const eurCash = result.trades.filter((t) => t.assetCategory === "CASH" && t.currency === "EUR"); + expect(eurCash).toHaveLength(0); + }); + + it("should emit SELL CASH for FCY→EUR conversion", () => { + const csv = [ + "Date,Reference,Ticker,ISIN,Type,Quantity,CCY,Price/share,Gross Amount,FX Rate,Fee,Net Amt.,Tax Amt.", + "10/05/2025 12:00:00,CN-0000000099,USD,,Conversion,,USD,,-500.00,0.92,,,-500.00,", + ].join("\n"); + const result = lightyearParser.parse(csv); + const fx = result.trades[0]!; + expect(fx.assetCategory).toBe("CASH"); + expect(fx.buySell).toBe("SELL"); + expect(fx.currency).toBe("USD"); + expect(fx.quantity).toBe("-500"); + }); + + it("should enable FX FIFO detection (autoConvert=false when CASH trades exist)", () => { + const result = lightyearParser.parse(LIGHTYEAR_CSV); + const hasCash = result.trades.some((t) => t.assetCategory === "CASH"); + const hasNonEurStk = result.trades.some((t) => t.assetCategory === "STK" && t.currency !== "EUR"); + expect(hasCash).toBe(true); + expect(hasNonEurStk).toBe(true); + }); + }); + // ------------------------------------------------------------------------- // Real fixture // ------------------------------------------------------------------------- diff --git a/tests/parsers/trading212.test.ts b/tests/parsers/trading212.test.ts index cf5d071..5aac11d 100644 --- a/tests/parsers/trading212.test.ts +++ b/tests/parsers/trading212.test.ts @@ -335,5 +335,65 @@ describe("trading212Parser", () => { expect(fractional).toBeDefined(); }); }); + + // ------------------------------------------------------------------------- + // Fractional currency normalization (GBX → GBP ÷100) + // ------------------------------------------------------------------------- + + describe("fractional currency (GBX)", () => { + const GBX_CSV = [ + HEADER, + "Market buy,2024-05-10 10:00:00,GB0031215220,BARC,Barclays PLC,100,185.50,GBX,0.86,,,18550.00,GBX,,gbx001", + "Market sell,2024-08-15 14:00:00,GB0031215220,BARC,Barclays PLC,100,210.00,GBX,0.85,,,21000.00,GBX,,gbx002", + "Dividend (Ordinary),2024-06-01 00:00:00,GB0031215220,BARC,Barclays PLC,,,GBX,0.86,,,450.00,GBX,,gbxdiv001", + "Dividend (Dividend tax),2024-06-01 00:00:00,GB0031215220,BARC,Barclays PLC,,,GBX,0.86,,,-45.00,GBX,,gbxtax001", + ].join("\n"); + + it("should normalize GBX price to GBP (÷100)", () => { + const result = trading212Parser.parse(GBX_CSV); + const buy = result.trades.find((t) => t.buySell === "BUY"); + expect(buy).toBeDefined(); + expect(buy!.currency).toBe("GBP"); + expect(buy!.tradePrice).toBe("1.855"); + expect(buy!.cost).toBe("-185.5"); + }); + + it("should normalize GBX sell proceeds to GBP (÷100)", () => { + const result = trading212Parser.parse(GBX_CSV); + const sell = result.trades.find((t) => t.buySell === "SELL"); + expect(sell).toBeDefined(); + expect(sell!.currency).toBe("GBP"); + expect(sell!.tradePrice).toBe("2.1"); + expect(sell!.proceeds).toBe("210"); + }); + + it("should normalize GBX dividends to GBP (÷100)", () => { + const result = trading212Parser.parse(GBX_CSV); + const div = result.cashTransactions.find((c) => c.type === "Dividends"); + expect(div).toBeDefined(); + expect(div!.currency).toBe("GBP"); + expect(div!.amount).toBe("4.5"); + }); + + it("should normalize GBX withholding tax to GBP (÷100)", () => { + const result = trading212Parser.parse(GBX_CSV); + const wht = result.cashTransactions.find((c) => c.type === "Withholding Tax"); + expect(wht).toBeDefined(); + expect(wht!.currency).toBe("GBP"); + expect(wht!.amount).toBe("-0.45"); + }); + + it("should NOT divide when currency is already GBP", () => { + const GBP_CSV = [ + HEADER, + "Market buy,2024-05-10 10:00:00,GB0031215220,BARC,Barclays PLC,100,1.855,GBP,0.86,,,185.50,GBP,,gbp001", + ].join("\n"); + const result = trading212Parser.parse(GBP_CSV); + const buy = result.trades[0]!; + expect(buy.currency).toBe("GBP"); + expect(buy.tradePrice).toBe("1.855"); + expect(buy.cost).toBe("-185.5"); + }); + }); });