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/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"); + }); + }); });