Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions src/parsers/trading212.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { base: string; divisor: Decimal }> = {
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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();

Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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;
}
Expand Down
60 changes: 60 additions & 0 deletions tests/parsers/trading212.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});

Loading