diff --git a/.gitignore b/.gitignore index 118d227..587eca4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage/ *.log *.csv !test/fixtures/Account.csv +!test/fixtures/Account-en.csv diff --git a/AGENTS.md b/AGENTS.md index 0b0c596..66d9495 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,9 +33,9 @@ Run `pnpm lint && pnpm typecheck && pnpm test` before proposing a change; that i ``` src/ csv/ tokenizer (papaparse) + Node row stream + shared options - dialects/ Dialect interface, French dialect, DialectRegistry + dialects/ Dialect interface, French/English/layout-fallback dialects, DialectRegistry records/ RawRecord — a normalised, dialect-agnostic row - classify/ Matcher registry + matchers (trade, fx, dividend, fees, interest, cash) + classify/ Matcher registry + matchers (trade, fx, dividend, fees, interest, cash, structural) group/ GroupingStrategy pipeline + Transaction union validate/ per-currency balance reconciliation portfolio/ positions, FIFO realized P/L, cash and fee roll-ups @@ -54,6 +54,8 @@ examples/dashboard/ The pipeline is `tokenizeCsv` → `mapRow` (dialect) → `ClassifierRegistry.classify` → `groupMovements` (strategy list) → optional `reconcileBalances` / `summarizePortfolio`. Every stage is swappable through `ParseOptions`; adding a locale, a description or a grouping rule should mean registering something, never editing a `switch`. +Header language, number format and description language are three independent axes of a DEGIRO export — changing the interface language rewrites line 1 and nothing else. Keep them independent in code: dialects own the header and the number/date formats, matchers own the wording, and neither may assume the other. `genericDialect` closes the set by matching the column layout alone; anything heuristic sets `heuristic: true` so `ParseResult.warnings` can say so. + ## Rules that are load-bearing - **The root entry imports no Node builtin.** `src/index.ts` and everything it reaches must stay free of `node:*`; anything needing a filesystem or streams goes behind `src/node.ts`. `test/entrypoints.test.ts` guards this and has caught a regression before. @@ -69,7 +71,7 @@ The pipeline is `tokenizeCsv` → `mapRow` (dialect) → `ClassifierRegistry.cla - TypeScript is strict with `noUncheckedIndexedAccess` and `verbatimModuleSyntax`. Type-only imports must use `import type` — `@typescript-eslint/consistent-type-imports` is an error, not a warning. - Prettier: single quotes, semicolons, trailing commas, 100 columns. `.csv` fixtures and `CHANGELOG.md` are excluded and must not be reformatted. - `examples/**/components/ui/**` is generated by the shadcn CLI and kept verbatim so it stays upgradable; two lint rules are disabled there. Do not hand-edit those files. -- `test/fixtures/Account.csv` is synthetic but byte-sensitive: French decimals, `U+202F` thousands separators, double-spaced product names, and running balances that reconcile exactly. Editing it usually means fixing the balances too. A real `Account.csv` at the repo root is git-ignored. +- `test/fixtures/Account.csv` is synthetic but byte-sensitive: French decimals, `U+202F` thousands separators, double-spaced product names, and running balances that reconcile exactly. Editing it usually means fixing the balances too. `test/fixtures/Account-en.csv` is the same body behind the English header; keep the two bodies identical, since a test asserts they classify the same. Both are unignored by name in `.gitignore`. A real `Account.csv` at the repo root is git-ignored. - New behaviour needs a test in `test/` (library) or `examples/dashboard/test/` (analytics). The analytics layer imports nothing from React precisely so it stays testable headlessly. ## Git and PRs diff --git a/README.md b/README.md index fa54c44..53c37bd 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ A modern, ESM-only TypeScript library that turns a DEGIRO account statement into - 🧮 **Exact money** with [`big.js`](https://github.com/MikeMcl/big.js) — no float drift - 🧩 **Extensible** dialects, classifiers and grouping strategies +- 🌐 **French and English exports** out of the box, and a layout-detecting fallback for every other language - 🧠 **Typed domain model** — discriminated unions for movements & transactions - 🪶 **Lenient parsing** — per-row problems are collected, never thrown - 🌊 **Streaming** parser for very large files @@ -108,6 +109,40 @@ const result = await parseDegiroStream(createReadStream('./Account.csv')); Parsing is **lenient**: a row with an unparseable date is dropped and reported as an `error`; a partially-parseable amount becomes a `warning`. The only thrown conditions are an empty input and a header that matches no dialect (`UnknownDialectError`). +## Supported exports + +DEGIRO localizes the **header row** of `Account.csv` to the interface language. Two dialects recognise a header by its words, and a third recognises the file by its shape when neither does: + +| Dialect | `id` | Header | +| ---------------- | --------- | --------------------------------------------------------------------------------- | +| `frenchDialect` | `fr` | `Date,Heure,Date de,Produit,Code ISIN,Description,FX,Mouvements,,Solde,,ID Ordre` | +| `englishDialect` | `en` | `Date,Time,Value date,Product,ISIN,Description,FX,Change,,Balance,,Order Id` | +| `genericDialect` | `generic` | any header in DEGIRO's column layout, whatever the labels say | + +All three share the positional column layout, and the two language-aware ones share `DD-MM-YYYY` dates. They differ in how they read numbers: `frenchDialect` expects space thousands separators and a comma decimal mark, while `englishDialect` accepts either that or the US `1,060.20` form — DEGIRO does **not** switch the body of the file to English number formatting when you switch the interface language, so an English export can carry either. When a value contains both `,` and `.`, the last one is the decimal mark; a lone separator is a decimal mark unless the value is a run of exact 3-digit groups (`1,060,200`). + +Descriptions are a separate axis: switching the interface language rewrites the header but leaves the description text alone, so an English-header export from a French account still says `Frais DEGIRO de courtage`. The built-in matchers therefore recognise both languages regardless of which dialect read the header. Anything they do not recognise becomes an `unknown` movement — never a dropped row. + +### The fallback, for every other language + +DEGIRO's column layout is the same in every language: twelve columns, where the mutation and the balance each pair a currency cell with an amount cell under a single header label — so the 9th and 11th header cells (0-based indices 8 and 10) are always empty. `genericDialect` recognises that shape and nothing else, reading no header text at all, and interprets dates (`DD-MM-YYYY`, `DD/MM/YYYY`, `YYYY-MM-DD`) and numbers by the same per-value rules as the English dialect. It is registered last, so a French or English export never reaches it. + +Because it is a guess, it says so: `dialect.heuristic` is `true` and a warning lands on `ParseResult.warnings` naming the line it was decided on. Nothing silently depends on it. + +```ts +const result = parseDegiroCsv(dutchCsv); +result.dialect.id; // 'generic' +result.dialect.heuristic; // true +result.warnings[0].message; // 'No dialect recognised this header, so the "generic" fallback ...' +``` + +Two smaller guesses ride along: + +- If the header tokenizes to a single cell, `parseDegiroCsv` retries with `;` and then a tab before giving up. Passing `delimiter` explicitly turns this off. +- `structuralTradeMatcher` and `structuralFxTradeMatcher` recover trades from the _shape_ of a description — ` @ ()` — and take the side from the sign of the mutation, money out being a buy. Both run at a negative priority, after every language-aware matcher has declined, so `Koop 1.060 SMI ETF@106,02 CHF (CH0019852802)` classifies as a buy without anyone teaching the library Dutch. + +Descriptions with no such shape — a dividend, a fee, a deposit in a language the matchers do not know — stay `unknown` with their amounts intact, so balances still reconcile and cash still adds up. Register a matcher for the wording to name them. + ## Domain model ### Movements @@ -237,27 +272,31 @@ Every stage is pluggable. You rarely need to fork the library to support a new e ### Custom dialect (new locale / layout) +French and English are built in; here is a third locale — Dutch headers, dot thousands, comma decimals: + ```ts -import { parseDegiroCsv, frenchDialect, parseFrenchDateTime, type Dialect } from 'libdegiro'; +import { parseDegiroCsv, DEGIRO_COLUMNS, parseDegiroDateTime, type Dialect } from 'libdegiro'; -const englishDialect: Dialect = { - id: 'en', - label: 'DEGIRO English', - columns: frenchDialect.columns, // same positional layout - matches: (header) => header.includes('Change') && header.includes('Balance'), +const dutchDialect: Dialect = { + id: 'nl', + label: 'DEGIRO Dutch', + columns: DEGIRO_COLUMNS, // the shared positional layout + matches: (header) => header.includes('Omschrijving') && header.includes('Mutatie'), parseDecimal: (raw) => { - const n = raw.trim().replace(/,/g, ''); // US thousands + const n = raw.trim().replace(/\./g, '').replace(',', '.'); // 1.060,20 return /^-?\d+(\.\d+)?$/.test(n) ? n : null; }, - parseDateTime: parseFrenchDateTime, - parseDate: (d) => parseFrenchDateTime(d), + parseDateTime: parseDegiroDateTime, + parseDate: (d) => parseDegiroDateTime(d), }; -parseDegiroCsv(csv, { dialects: [englishDialect] }); +parseDegiroCsv(csv, { dialects: [dutchDialect] }); // or force it, skipping detection: -parseDegiroCsv(csv, { dialect: englishDialect }); +parseDegiroCsv(csv, { dialect: dutchDialect }); ``` +A dialect registered through `dialects` replaces the built-ins, including the fallback; pass `createDefaultDialectRegistry().register(dutchDialect, { prepend: true })` to keep them. + ### Custom classifier (new movement description) ```ts @@ -318,4 +357,4 @@ pnpm build # tsdown -> dist/ (ESM + .d.ts + sourcemaps) ### Test fixture -`test/fixtures/Account.csv` is a **synthetic** statement, not a real export. It mirrors the shape of a genuine French DEGIRO file — column layout, movement types, order-id grouping, French decimals with `U+202F` thousands separators, double-spaced product names — and its running balances reconcile exactly, but every figure, date, ISIN and order id is fabricated. Drop your own `Account.csv` at the repo root to try the library against real data; it is git-ignored. +`test/fixtures/Account.csv` is a **synthetic** statement, not a real export. It mirrors the shape of a genuine French DEGIRO file — column layout, movement types, order-id grouping, French decimals with `U+202F` thousands separators, double-spaced product names — and its running balances reconcile exactly, but every figure, date, ISIN and order id is fabricated. `test/fixtures/Account-en.csv` is the same statement behind an English header, which is exactly what DEGIRO produces when you switch the interface language. Drop your own `Account.csv` at the repo root to try the library against real data; it is git-ignored. diff --git a/examples/dashboard/src/components/dropzone.tsx b/examples/dashboard/src/components/dropzone.tsx index 22c72b6..1deafbd 100644 --- a/examples/dashboard/src/components/dropzone.tsx +++ b/examples/dashboard/src/components/dropzone.tsx @@ -1,10 +1,14 @@ -import { useCallback, useRef, useState, type DragEvent } from 'react'; -import { FileUp, ShieldCheck } from 'lucide-react'; +import { useCallback, useRef, useState, type DragEvent, type ReactNode } from 'react'; +import { Download, FileUp, ShieldCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { SAMPLE_FILE_NAME, sampleCsv } from '@/lib/sample'; import { useStatement } from '@/state/statement-context'; +function Ui({ children }: { children: ReactNode }) { + return {children}; +} + export function Dropzone() { const { load, state } = useStatement(); const [dragging, setDragging] = useState(false); @@ -53,7 +57,7 @@ export function Dropzone() {

Drop your DEGIRO Account.csv here

- Export it from DEGIRO under Inbox → Account statement. + English and French statements are both recognised.

@@ -83,6 +87,39 @@ export function Dropzone() { ) : null} +
+

+ + Exporting Account.csv from DEGIRO +

+
    +
  1. + Sign in to degiro.com in a browser. The statement export lives in the web + client. +
  2. +
  3. + Open Inbox, then the Account statement tab. +
  4. +
  5. + Set Start date to the day you opened the account, or anything earlier, and{' '} + End date to today. Every number here is computed from the rows in the file, so + a narrower range silently gives you partial positions, fees and realized P/L. +
  6. +
  7. + Leave Curr. on All and the product search empty, so no currency or + instrument is filtered out. +
  8. +
  9. + Hide cash movements only changes the table on screen. The export contains every + row either way, and this dashboard needs those rows to reconcile your balances. +
  10. +
  11. + Click the download button at the top right of the table and choose CSV. Drop + the file it saves — Account.csv — above. +
  12. +
+
+

Your statement is parsed in this tab and never uploaded. The page blocks all network access, diff --git a/examples/dashboard/src/components/sections/health.tsx b/examples/dashboard/src/components/sections/health.tsx index 5f3bcb2..115fda0 100644 --- a/examples/dashboard/src/components/sections/health.tsx +++ b/examples/dashboard/src/components/sections/health.tsx @@ -3,6 +3,7 @@ import type { BalanceDiscrepancy } from 'libdegiro'; import { CheckCircle2, Copy, Info, TriangleAlert } from 'lucide-react'; import { describeHealthNotes, + plural, describeHealthProblems, diagnosticsText, explainDiscrepancy, @@ -109,9 +110,9 @@ export function HealthSection() { - Parsed {health.rows} rows using the {result.dialect.id} dialect. {health.errors.length}{' '} - errors, {health.warnings.length} warnings, {health.unknown.length} unrecognised - descriptions. + Parsed {plural(health.rows, 'row')} using the {result.dialect.id} dialect.{' '} + {plural(health.errors.length, 'error')}, {plural(health.warnings.length, 'warning')},{' '} + {plural(health.unknown.length, 'unrecognised description')}. {problems.length > 0 ? (

    diff --git a/examples/dashboard/src/lib/analytics/health.ts b/examples/dashboard/src/lib/analytics/health.ts index 0736d91..1791258 100644 --- a/examples/dashboard/src/lib/analytics/health.ts +++ b/examples/dashboard/src/lib/analytics/health.ts @@ -26,6 +26,8 @@ export interface HealthReport { readonly unparseableExchanges: number; readonly reconciliation: ReconciliationReport; readonly range: DateRange | null; + /** `true` when the header matched no language and the layout fallback read the file. */ + readonly heuristicDialect: boolean; readonly ok: boolean; } @@ -54,6 +56,7 @@ export function buildHealthReport(result: ParseResult, unparseableExchanges: num unparseableExchanges, reconciliation, range: statementRange(result.movements), + heuristicDialect: result.dialect.heuristic === true, ok: result.errors.length === 0 && unknown.length === 0 && @@ -62,7 +65,8 @@ export function buildHealthReport(result: ParseResult, unparseableExchanges: num }; } -const plural = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? '' : 's'}`; +export const plural = (count: number, noun: string): string => + `${count} ${noun}${count === 1 ? '' : 's'}`; /** * Why `ok` is false, in the user's terms. @@ -107,11 +111,22 @@ export function describeHealthProblems(report: HealthReport): string[] { * ignore the panel; hiding it entirely leaves a real number unexplained. */ export function describeHealthNotes(report: HealthReport): string[] { + const notes: string[] = []; + + if (report.heuristicDialect) { + notes.push( + 'No dialect recognised this header, so the file was read by its column layout alone and its dates and amounts were interpreted by guesswork. Dates, amounts and balances above are worth a spot-check against the statement.', + ); + } + const rounding = report.reconciliation.rounding.length; - if (rounding === 0) return []; - return [ - `${plural(rounding, 'balance transition')} ${rounding === 1 ? 'is' : 'are'} off by less than one centime. DEGIRO rounds a half-unit price down in its amount column and up in its balance column, so the statement disagrees with itself. Nothing here is a parsing error.`, - ]; + if (rounding > 0) { + notes.push( + `${plural(rounding, 'balance transition')} ${rounding === 1 ? 'is' : 'are'} off by less than one centime. DEGIRO rounds a half-unit price down in its amount column and up in its balance column, so the statement disagrees with itself. Nothing here is a parsing error.`, + ); + } + + return notes; } /** The one-sentence reading of a discrepancy: what it means and what to do. */ diff --git a/examples/dashboard/test/positions.test.ts b/examples/dashboard/test/positions.test.ts index 02cc383..6f826d8 100644 --- a/examples/dashboard/test/positions.test.ts +++ b/examples/dashboard/test/positions.test.ts @@ -146,6 +146,28 @@ describe('describeHealthProblems', () => { expect(describeHealthNotes(report)[0]).toContain('1 balance transition is off'); }); + it('warns when the layout fallback read the file, ahead of any rounding note', () => { + const dutchCsv = [ + 'Datum,Tijd,Valutadatum,Product,ISIN,Omschrijving,FX,Mutatie,,Saldo,,Order Id', + '18-11-2024,00:00,18-11-2024,,,Storting,,CHF,"9.000,00",CHF,"9.000,00",', + '', + ].join('\n'); + const parsed = parseDegiroCsv(dutchCsv); + const report = withDiscrepancies(buildHealthReport(parsed, 0), [ + discrepancy('rounding', '-0.01'), + ]); + + expect(report.heuristicDialect).toBe(true); + const notes = describeHealthNotes(report); + expect(notes).toHaveLength(2); + expect(notes[0]).toContain('read by its column layout alone'); + expect(notes[1]).toContain('1 balance transition is off'); + }); + + it('says nothing about the dialect when the header was recognised', () => { + expect(buildHealthReport(result, 0).heuristicDialect).toBe(false); + }); + it('explains a half-centime rounding from the row that caused it', () => { const entry = discrepancy('rounding', '-0.01', { statedMutation: new Money('-1528.28', 'CHF'), diff --git a/src/classify/descriptions.ts b/src/classify/descriptions.ts index f6bb298..4d542b6 100644 --- a/src/classify/descriptions.ts +++ b/src/classify/descriptions.ts @@ -1,19 +1,23 @@ import type { Dialect } from '../dialects/types'; import { Money } from '../money/money'; -/** Leading quantity (digits with space thousands separators) then the remainder. */ -const LEADING_QTY = /^([0-9][0-9\u00a0\u202f ]*)(.*)$/; -/** ` ()` tail of a trade description. */ -const PRICE_TAIL = /^(.+?)\s+([A-Za-z]{3})\s+\(([^)]*)\)\s*$/; -/** `Achat|Vente @` */ -const TRADE = /^(Achat|Vente)\s+(.+?)@(.+)$/; +/** Leading quantity: digits, then any number of grouped thousands. */ +const LEADING_QTY = /^[0-9]+(?:[ .,\u00a0\u202f][0-9]{3})*/; +const QTY_GROUPING = /[ .,\u00a0\u202f]/g; +/** `Achat|Vente|Buy|Sell` and the whitespace that follows it. */ +const TRADE_VERB = /^(Achat|Vente|Buy|Sell)\s+/i; +const BUY_SIDE = /^(achat|buy)$/i; +const CURRENCY = /^[A-Za-z]{3}$/; +const DIGIT = /[0-9]/; +const TRAILING_SPACE = /\s$/; /** Settlement prefix on FX trade rows. */ -const FX_SETTLEMENT_PREFIX = /^R[èe]glement transaction devise:\s*/i; +const FX_SETTLEMENT_PREFIX = + /^(?:R[èe]glement transaction devise|(?:Currency|FX)\s+(?:transaction\s+)?settlement)\s*:\s*/i; /** Currency pair such as `EUR/CHF`. */ const FX_PAIR = /^[A-Za-z]{3}\/[A-Za-z]{3}$/; -/** `Virement vers|depuis ... : ` */ -const CASH_TRANSFER = - /^Virement\s+(vers|depuis)\b.*:\s*([0-9][0-9\u00a0\u202f .,]*?)\s+([A-Za-z]{3})\s*$/i; +/** `Virement|Transfer|Deposit|Withdrawal vers|depuis|to|from ...`, up to the colon. */ +const CASH_TRANSFER = /^(?:Virement|Transfer|Deposit|Withdrawal)\s+(vers|depuis|to|from)\b/i; +const TO_CASH_ACCOUNT = /^(vers|to)$/i; /** Parse a localized integer quantity (with space thousands separators). */ export function parseQuantity(raw: string, dialect: Dialect): number | null { @@ -32,36 +36,88 @@ export interface ParsedTrade { readonly isin: string | null; } +export type ParsedTradeShape = Omit; + +function splitTrailingCurrency(text: string): { head: string; currency: string } | null { + const currency = text.slice(-3); + if (!CURRENCY.test(currency)) return null; + const head = text.slice(0, -3); + if (!TRAILING_SPACE.test(head)) return null; + return { head: head.trim(), currency }; +} + +function parsePriceTail( + priceTail: string, + dialect: Dialect, +): { unitPrice: Money | null; isin: string | null } { + const trimmed = priceTail.trim(); + const open = trimmed.lastIndexOf('('); + if (open < 0 || !trimmed.endsWith(')')) return { unitPrice: null, isin: null }; + + const priceAndCurrency = splitTrailingCurrency(trimmed.slice(0, open).trim()); + if (!priceAndCurrency) return { unitPrice: null, isin: null }; + + const price = dialect.parseDecimal(priceAndCurrency.head); + return { + unitPrice: price === null ? null : new Money(price, priceAndCurrency.currency), + isin: trimmed.slice(open + 1, -1).trim() || null, + }; +} + +function parseTradeBody( + qtyAndProduct: string, + priceTail: string, + dialect: Dialect, +): ParsedTradeShape { + const qtyMatch = LEADING_QTY.exec(qtyAndProduct); + const quantity = qtyMatch ? parseQuantity(qtyMatch[0].replace(QTY_GROUPING, ''), dialect) : null; + const rest = qtyMatch ? qtyAndProduct.slice(qtyMatch[0].length) : qtyAndProduct; + + return { quantity, product: rest.trim() || null, ...parsePriceTail(priceTail, dialect) }; +} + +function splitAtPrice(text: string, from: number): { left: string; right: string } | null { + const at = text.indexOf('@', from); + if (at <= 0 || at === text.length - 1) return null; + return { left: text.slice(from, at), right: text.slice(at + 1) }; +} + /** * Parse a trade description such as * `"Achat 42 iShares Core MSCI World UCITS ETF USD (Acc)@96,11 CHF (IE00B4L5Y983)"`. * Returns `null` when the text is not a trade. */ export function parseTradeDescription(description: string, dialect: Dialect): ParsedTrade | null { - const trade = TRADE.exec(description.trim()); - if (!trade) return null; + const trimmed = description.trim(); + const verb = TRADE_VERB.exec(trimmed); + if (!verb) return null; - const side = trade[1] === 'Achat' ? 'buy' : 'sell'; - const qtyAndProduct = trade[2] ?? ''; - const priceTail = trade[3] ?? ''; + const split = splitAtPrice(trimmed, verb[0].length); + if (!split) return null; - const qtyMatch = LEADING_QTY.exec(qtyAndProduct); - const quantity = qtyMatch ? parseQuantity(qtyMatch[1] ?? '', dialect) : null; - const product = qtyMatch ? (qtyMatch[2] ?? '').trim() || null : qtyAndProduct.trim() || null; - - const priceMatch = PRICE_TAIL.exec(priceTail); - let unitPrice: Money | null = null; - let isin: string | null = null; - if (priceMatch) { - const priceDecimal = dialect.parseDecimal(priceMatch[1] ?? ''); - const currency = priceMatch[2] ?? ''; - if (priceDecimal !== null && currency !== '') { - unitPrice = new Money(priceDecimal, currency); - } - isin = (priceMatch[3] ?? '').trim() || null; - } + const side = BUY_SIDE.test(verb[1] ?? '') ? 'buy' : 'sell'; + return { side, ...parseTradeBody(split.left, split.right, dialect) }; +} - return { side, quantity, product, unitPrice, isin }; +export interface ParsedUnlabelledTrade extends ParsedTradeShape { + readonly prefix: string; +} + +export function parseUnlabelledTradeDescription( + description: string, + dialect: Dialect, +): ParsedUnlabelledTrade | null { + const trimmed = description.trim(); + const quantity = trimmed.search(DIGIT); + if (quantity < 0) return null; + + const split = splitAtPrice(trimmed, quantity); + if (!split) return null; + + return { + prefix: trimmed.slice(0, quantity).trim(), + ...parseTradeBody(split.left, split.right, dialect), + }; } /** Structured result of parsing a currency-pair (FX) trade description. */ @@ -116,13 +172,19 @@ export function parseCashTransferDescription( description: string, dialect: Dialect, ): ParsedCashTransfer | null { - const match = CASH_TRANSFER.exec(description.trim()); + const trimmed = description.trim(); + const match = CASH_TRANSFER.exec(trimmed); if (!match) return null; - const direction = (match[1] ?? '').toLowerCase() === 'vers' ? 'toCashAccount' : 'fromCashAccount'; - const decimal = dialect.parseDecimal(match[2] ?? ''); - const currency = match[3] ?? ''; - const amount = decimal !== null && currency !== '' ? new Money(decimal, currency) : null; + const colon = trimmed.lastIndexOf(':'); + if (colon < 0) return null; + + const stated = splitTrailingCurrency(trimmed.slice(colon + 1).trim()); + if (!stated || !DIGIT.test(stated.head.charAt(0))) return null; + + const direction = TO_CASH_ACCOUNT.test(match[1] ?? '') ? 'toCashAccount' : 'fromCashAccount'; + const decimal = dialect.parseDecimal(stated.head); + const amount = decimal === null ? null : new Money(decimal, stated.currency); return { direction, amount }; } diff --git a/src/classify/index.ts b/src/classify/index.ts index db20c05..6074751 100644 --- a/src/classify/index.ts +++ b/src/classify/index.ts @@ -6,6 +6,7 @@ export { fxTradeMatcher, fxConversionMatcher } from './matchers/fx'; export { dividendMatcher, dividendTaxMatcher, capitalReturnMatcher } from './matchers/dividend'; export { brokerageFeeMatcher, connectivityFeeMatcher } from './matchers/fees'; export { interestMatcher } from './matchers/interest'; +export { structuralFxTradeMatcher, structuralTradeMatcher } from './matchers/structural'; export { cashSweepMatcher, depositMatcher, diff --git a/src/classify/matchers/cash.ts b/src/classify/matchers/cash.ts index 209f700..a4a2b51 100644 --- a/src/classify/matchers/cash.ts +++ b/src/classify/matchers/cash.ts @@ -2,8 +2,8 @@ import type { Matcher } from '../types'; import { parseCashTransferDescription } from '../descriptions'; const CASH_SWEEP = /^degiro cash sweep transfer/i; -const DEPOSIT = /^versement de fonds/i; -const WITHDRAWAL = /^retrait de fonds/i; +const DEPOSIT = /^(?:versement de fonds|(?:processed\s+)?(?:flatex\s+)?deposit)\b/i; +const WITHDRAWAL = /^(?:retrait de fonds|(?:processed\s+)?(?:flatex\s+)?withdrawal)\b/i; /** Matches a cash sweep between the DEGIRO account and the cash account. */ export const cashSweepMatcher: Matcher = { diff --git a/src/classify/matchers/dividend.ts b/src/classify/matchers/dividend.ts index a476389..692d42c 100644 --- a/src/classify/matchers/dividend.ts +++ b/src/classify/matchers/dividend.ts @@ -1,8 +1,8 @@ import type { Matcher } from '../types'; -const DIVIDEND_TAX = /^imp[oô]ts? sur (le )?dividende/i; -const DIVIDEND = /^dividende\b/i; -const CAPITAL_RETURN = /^remboursement de capital/i; +const DIVIDEND_TAX = /^(?:imp[oô]ts? sur (?:le )?dividende|dividend tax|withholding tax)/i; +const DIVIDEND = /^dividende?\b/i; +const CAPITAL_RETURN = /^(?:remboursement de capital|capital return|return of capital)/i; /** Matches dividend tax withholding (`Impôts sur dividende`). */ export const dividendTaxMatcher: Matcher = { diff --git a/src/classify/matchers/fees.ts b/src/classify/matchers/fees.ts index fd9c596..a6327b8 100644 --- a/src/classify/matchers/fees.ts +++ b/src/classify/matchers/fees.ts @@ -1,8 +1,9 @@ import type { Matcher } from '../types'; import { extractYear } from '../descriptions'; -const BROKERAGE = /^frais degiro de courtage/i; -const CONNECTIVITY = /^frais de connexion aux places boursi[èe]res/i; +const BROKERAGE = /^(?:frais degiro de courtage|degiro transaction)/i; +const CONNECTIVITY = + /^(?:frais de connexion aux places boursi[èe]res|degiro exchange connect(?:ion|ivity) fee)/i; /** Matches a brokerage / third-party transaction fee. */ export const brokerageFeeMatcher: Matcher = { diff --git a/src/classify/matchers/fx.ts b/src/classify/matchers/fx.ts index 9bfb4fb..5b0a8c2 100644 --- a/src/classify/matchers/fx.ts +++ b/src/classify/matchers/fx.ts @@ -2,7 +2,8 @@ import type { Matcher } from '../types'; import { parseFxTradeDescription } from '../descriptions'; /** `Opération de change - Crédit/Débit` (accent spelling varies between legs). */ -const FX_CONVERSION = /^op[ée]ration de change\s*-\s*(cr[ée]dit|d[ée]bit)/i; +const FX_CONVERSION = + /^(?:op[ée]ration de change|currency exchange|fx)[\s(-]*(cr[ée]dit|d[ée]bit)/i; /** Matches a currency-pair trade (`Achat/Vente EUR/CHF@ CCY`). */ export const fxTradeMatcher: Matcher = { diff --git a/src/classify/matchers/structural.ts b/src/classify/matchers/structural.ts new file mode 100644 index 0000000..efc2922 --- /dev/null +++ b/src/classify/matchers/structural.ts @@ -0,0 +1,63 @@ +import type { Money } from '../../money/money'; +import type { Matcher, TradeSide } from '../types'; +import { parseUnlabelledTradeDescription } from '../descriptions'; + +const ISIN = /^[A-Z]{2}[A-Z0-9]{9}[0-9]$/; +const FX_PAIR = /^[A-Za-z]{3}\/[A-Za-z]{3}$/; +const STRUCTURAL_PRIORITY = -10; + +function sideFromMutation(mutation: Money | null): TradeSide | null { + if (mutation === null || mutation.isZero()) return null; + return mutation.isNegative() ? 'buy' : 'sell'; +} + +export const structuralFxTradeMatcher: Matcher = { + name: 'structuralFxTrade', + priority: STRUCTURAL_PRIORITY + 1, + match({ record, dialect }) { + const parsed = parseUnlabelledTradeDescription(record.description, dialect); + if (!parsed || !parsed.product || !FX_PAIR.test(parsed.product)) return null; + + const side = sideFromMutation(record.mutation); + if (side === null) return null; + + return { + kind: 'fxTrade', + side, + pair: parsed.product, + quantity: parsed.quantity ?? 0, + rate: parsed.unitPrice, + settlement: parsed.prefix.includes(':'), + orderId: record.orderId, + amount: record.mutation, + record, + }; + }, +}; + +export const structuralTradeMatcher: Matcher = { + name: 'structuralTrade', + priority: STRUCTURAL_PRIORITY, + match({ record, dialect }) { + const parsed = parseUnlabelledTradeDescription(record.description, dialect); + if (!parsed || parsed.unitPrice === null) return null; + + const isin = record.isin ?? parsed.isin; + if (isin === null || !ISIN.test(isin)) return null; + + const side = sideFromMutation(record.mutation); + if (side === null) return null; + + return { + kind: side, + side, + quantity: parsed.quantity ?? 0, + unitPrice: parsed.unitPrice, + product: record.product ?? parsed.product, + isin, + orderId: record.orderId, + amount: record.mutation, + record, + }; + }, +}; diff --git a/src/classify/registry.ts b/src/classify/registry.ts index 5a9e038..056c90d 100644 --- a/src/classify/registry.ts +++ b/src/classify/registry.ts @@ -6,6 +6,7 @@ import { fxTradeMatcher, fxConversionMatcher } from './matchers/fx'; import { dividendMatcher, dividendTaxMatcher, capitalReturnMatcher } from './matchers/dividend'; import { brokerageFeeMatcher, connectivityFeeMatcher } from './matchers/fees'; import { interestMatcher } from './matchers/interest'; +import { structuralFxTradeMatcher, structuralTradeMatcher } from './matchers/structural'; import { cashSweepMatcher, depositMatcher, @@ -80,10 +81,12 @@ export const defaultMatchers: readonly Matcher[] = [ brokerageFeeMatcher, connectivityFeeMatcher, interestMatcher, - depositMatcher, - withdrawalMatcher, cashSweepMatcher, cashTransferMatcher, + depositMatcher, + withdrawalMatcher, + structuralFxTradeMatcher, + structuralTradeMatcher, ]; /** Create a registry pre-populated with all built-in matchers. */ diff --git a/src/dialects/common.ts b/src/dialects/common.ts new file mode 100644 index 0000000..7a39e81 --- /dev/null +++ b/src/dialects/common.ts @@ -0,0 +1,84 @@ +import type { CsvRow } from '../csv/tokenizer'; +import type { ColumnMap } from './types'; + +export const DEGIRO_COLUMNS: ColumnMap = { + date: 0, + time: 1, + valueDate: 2, + product: 3, + isin: 4, + description: 5, + fx: 6, + mutationCurrency: 7, + mutationAmount: 8, + balanceCurrency: 9, + balanceAmount: 10, + orderId: 11, +}; + +export const SPACE_SEPARATORS = /[\s\u00a0\u202f]/g; + +const DMY_DASH = /^(\d{2})-(\d{2})-(\d{4})$/; +const DMY_SLASH = /^(\d{2})\/(\d{2})\/(\d{4})$/; +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; +const HM = /^(\d{1,2}):(\d{2})$/; + +export function hasHeaderTokens(header: CsvRow, tokens: readonly string[]): boolean { + const cells = header.map((cell) => cell.trim()); + return tokens.every((token) => cells.includes(token)); +} + +function atUtc(year: string, month: string, day: string, time: string): Date | null { + const timeMatch = HM.exec(time.trim()); + if (!timeMatch) return null; + const [, hh, min] = timeMatch; + + const result = new Date( + Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hh), Number(min)), + ); + if ( + result.getUTCDate() !== Number(day) || + result.getUTCMonth() !== Number(month) - 1 || + result.getUTCFullYear() !== Number(year) + ) { + return null; + } + return result; +} + +export function parseDegiroDateTime(date: string, time = '00:00'): Date | null { + const match = DMY_DASH.exec(date.trim()); + if (!match) return null; + const [, dd, mm, yyyy] = match; + return atUtc(yyyy!, mm!, dd!, time); +} + +export function parseFlexibleDateTime(date: string, time = '00:00'): Date | null { + const trimmed = date.trim(); + + const iso = ISO_DATE.exec(trimmed); + if (iso) return atUtc(iso[1]!, iso[2]!, iso[3]!, time); + + const slashed = DMY_SLASH.exec(trimmed); + if (slashed) return atUtc(slashed[3]!, slashed[2]!, slashed[1]!, time); + + return parseDegiroDateTime(trimmed, time); +} + +export function parseFlexibleDecimal(raw: string): string | null { + const trimmed = raw.trim().replace(SPACE_SEPARATORS, ''); + if (trimmed === '') return null; + + const lastComma = trimmed.lastIndexOf(','); + const lastDot = trimmed.lastIndexOf('.'); + let normalized: string; + if (lastComma >= 0 && lastDot >= 0) { + const grouping = lastComma > lastDot ? '.' : ','; + normalized = trimmed.split(grouping).join('').replace(',', '.'); + } else { + const parts = trimmed.split(lastComma >= 0 ? ',' : '.'); + const grouped = parts.length > 2 && parts.slice(1).every((part) => /^\d{3}$/.test(part)); + normalized = grouped ? parts.join('') : parts.join('.'); + } + return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null; +} diff --git a/src/dialects/en.ts b/src/dialects/en.ts new file mode 100644 index 0000000..d6c3190 --- /dev/null +++ b/src/dialects/en.ts @@ -0,0 +1,32 @@ +import type { CsvRow } from '../csv/tokenizer'; +import type { Dialect } from './types'; +import { + DEGIRO_COLUMNS, + hasHeaderTokens, + parseDegiroDateTime, + parseFlexibleDecimal, +} from './common'; + +const ENGLISH_HEADER_TOKENS = [ + 'Date', + 'Time', + 'Product', + 'ISIN', + 'Description', + 'Change', + 'Balance', +] as const; + +export const englishDialect: Dialect = { + id: 'en', + label: 'DEGIRO English (Account.csv)', + columns: DEGIRO_COLUMNS, + matches(header: CsvRow): boolean { + return hasHeaderTokens(header, ENGLISH_HEADER_TOKENS); + }, + parseDecimal: parseFlexibleDecimal, + parseDateTime: parseDegiroDateTime, + parseDate(date: string): Date | null { + return parseDegiroDateTime(date); + }, +}; diff --git a/src/dialects/fr.ts b/src/dialects/fr.ts index 63ba1a5..3d4212d 100644 --- a/src/dialects/fr.ts +++ b/src/dialects/fr.ts @@ -1,21 +1,6 @@ import type { CsvRow } from '../csv/tokenizer'; -import type { ColumnMap, Dialect } from './types'; - -/** Positional column layout of the French DEGIRO `Account.csv` export. */ -const FRENCH_COLUMNS: ColumnMap = { - date: 0, - time: 1, - valueDate: 2, - product: 3, - isin: 4, - description: 5, - fx: 6, - mutationCurrency: 7, - mutationAmount: 8, - balanceCurrency: 9, - balanceAmount: 10, - orderId: 11, -}; +import type { Dialect } from './types'; +import { DEGIRO_COLUMNS, SPACE_SEPARATORS, hasHeaderTokens, parseDegiroDateTime } from './common'; /** Header tokens that uniquely identify a French export. */ const FRENCH_HEADER_TOKENS = [ @@ -28,11 +13,6 @@ const FRENCH_HEADER_TOKENS = [ 'Solde', ] as const; -const DMY = /^(\d{2})-(\d{2})-(\d{4})$/; -const HM = /^(\d{1,2}):(\d{2})$/; -/** Spaces used as thousands separators, including NBSP / narrow NBSP. */ -const THOUSANDS_SEPARATORS = /[\s\u00a0\u202f]/g; - /** * Parse a French/European decimal string into a plain decimal string. * @@ -42,31 +22,12 @@ const THOUSANDS_SEPARATORS = /[\s\u00a0\u202f]/g; export function parseFrenchDecimal(raw: string): string | null { const trimmed = raw.trim(); if (trimmed === '') return null; - const normalized = trimmed.replace(THOUSANDS_SEPARATORS, '').replace(/,/g, '.'); + const normalized = trimmed.replace(SPACE_SEPARATORS, '').replace(/,/g, '.'); return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null; } /** Parse a `DD-MM-YYYY` date (optionally with `HH:MM` time) into a UTC `Date`. */ -export function parseFrenchDateTime(date: string, time = '00:00'): Date | null { - const dateMatch = DMY.exec(date.trim()); - if (!dateMatch) return null; - const timeMatch = HM.exec(time.trim()); - if (!timeMatch) return null; - - const [, dd, mm, yyyy] = dateMatch; - const [, hh, min] = timeMatch; - const ms = Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd), Number(hh), Number(min)); - const result = new Date(ms); - // Guard against overflow (e.g. 32-13-2026 silently rolling over). - if ( - result.getUTCDate() !== Number(dd) || - result.getUTCMonth() !== Number(mm) - 1 || - result.getUTCFullYear() !== Number(yyyy) - ) { - return null; - } - return result; -} +export const parseFrenchDateTime = parseDegiroDateTime; /** * Built-in dialect for the French DEGIRO `Account.csv` export. @@ -77,14 +38,13 @@ export function parseFrenchDateTime(date: string, time = '00:00'): Date | null { export const frenchDialect: Dialect = { id: 'fr', label: 'DEGIRO French (Account.csv)', - columns: FRENCH_COLUMNS, + columns: DEGIRO_COLUMNS, matches(header: CsvRow): boolean { - const cells = header.map((c) => c.trim()); - return FRENCH_HEADER_TOKENS.every((token) => cells.includes(token)); + return hasHeaderTokens(header, FRENCH_HEADER_TOKENS); }, parseDecimal: parseFrenchDecimal, - parseDateTime: parseFrenchDateTime, + parseDateTime: parseDegiroDateTime, parseDate(date: string): Date | null { - return parseFrenchDateTime(date); + return parseDegiroDateTime(date); }, }; diff --git a/src/dialects/generic.ts b/src/dialects/generic.ts new file mode 100644 index 0000000..8d20b51 --- /dev/null +++ b/src/dialects/generic.ts @@ -0,0 +1,29 @@ +import type { CsvRow } from '../csv/tokenizer'; +import type { Dialect } from './types'; +import { DEGIRO_COLUMNS, parseFlexibleDateTime, parseFlexibleDecimal } from './common'; + +const COLUMN_COUNT = 12; +const UNLABELLED_COLUMNS = [8, 10] as const; +const LABELLED_COLUMNS = [0, 1, 2, 3, 4, 5, 6, 7, 9, 11] as const; + +export function matchesDegiroLayout(header: CsvRow): boolean { + if (header.length !== COLUMN_COUNT) return false; + const cells = header.map((cell) => cell.trim()); + return ( + UNLABELLED_COLUMNS.every((index) => cells[index] === '') && + LABELLED_COLUMNS.every((index) => (cells[index] ?? '') !== '') + ); +} + +export const genericDialect: Dialect = { + id: 'generic', + label: 'DEGIRO (layout detected, language unknown)', + heuristic: true, + columns: DEGIRO_COLUMNS, + matches: matchesDegiroLayout, + parseDecimal: parseFlexibleDecimal, + parseDateTime: parseFlexibleDateTime, + parseDate(date: string): Date | null { + return parseFlexibleDateTime(date); + }, +}; diff --git a/src/dialects/index.ts b/src/dialects/index.ts index 277e659..df1498d 100644 --- a/src/dialects/index.ts +++ b/src/dialects/index.ts @@ -1,3 +1,6 @@ export * from './types'; +export * from './common'; export * from './fr'; +export * from './en'; +export * from './generic'; export * from './registry'; diff --git a/src/dialects/registry.ts b/src/dialects/registry.ts index 1df392c..7d2c1a1 100644 --- a/src/dialects/registry.ts +++ b/src/dialects/registry.ts @@ -2,6 +2,8 @@ import type { CsvRow } from '../csv/tokenizer'; import { UnknownDialectError } from '../errors'; import type { Dialect } from './types'; import { frenchDialect } from './fr'; +import { englishDialect } from './en'; +import { genericDialect } from './generic'; /** Options for registering a dialect. */ export interface RegisterDialectOptions { @@ -62,7 +64,7 @@ export class DialectRegistry { /** Registry pre-populated with all built-in dialects. */ export function createDefaultDialectRegistry(): DialectRegistry { - return new DialectRegistry([frenchDialect]); + return new DialectRegistry([frenchDialect, englishDialect, genericDialect]); } /** Shared registry containing the built-in dialects. */ diff --git a/src/dialects/types.ts b/src/dialects/types.ts index c35a738..54960c2 100644 --- a/src/dialects/types.ts +++ b/src/dialects/types.ts @@ -47,6 +47,7 @@ export interface Dialect { readonly label: string; /** Column index mapping for rows of this dialect. */ readonly columns: ColumnMap; + readonly heuristic?: boolean; /** Returns `true` if this dialect recognises the given header row. */ matches(header: CsvRow): boolean; /** diff --git a/src/internal.ts b/src/internal.ts index 5a0401a..bd533f8 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -6,7 +6,7 @@ import { defaultGroupingStrategies, groupMovements } from './group/grouper'; import type { GroupingStrategy } from './group/grouper'; import type { Transaction } from './group/transaction'; import type { Movement } from './classify/types'; -import type { ParseIssue } from './errors'; +import { createIssue, type ParseIssue } from './errors'; /** Options shared by every parsing entry point. */ export interface ParseOptions { @@ -53,6 +53,18 @@ export function resolveDialectRegistry(dialects: ParseOptions['dialects']): Dial return new DialectRegistry(dialects); } +export function dialectIssues(dialect: Dialect, header: readonly string[]): ParseIssue[] { + if (!dialect.heuristic) return []; + return [ + createIssue( + 'warning', + 'map', + `No dialect recognised this header, so the "${dialect.id}" fallback read the file by its column layout alone; amounts and dates were interpreted heuristically`, + { line: 1, raw: [...header] }, + ), + ]; +} + /** Classify + group already-mapped records into a {@link ParseResult}. */ export function assembleResult(input: { readonly dialect: Dialect; diff --git a/src/io/stream.ts b/src/io/stream.ts index 1d32d96..06ea5e5 100644 --- a/src/io/stream.ts +++ b/src/io/stream.ts @@ -5,6 +5,7 @@ import { DegiroError, type ParseIssue } from '../errors'; import type { Dialect } from '../dialects/types'; import { assembleResult, + dialectIssues, resolveDialectRegistry, type ParseOptions, type ParseResult, @@ -37,6 +38,7 @@ export async function parseDegiroStream( if (header === null) { header = row; if (dialect === null) dialect = registry.detect(header); + issues.push(...dialectIssues(dialect, header)); continue; } const result = mapRow(row, dialect!, line); diff --git a/src/parse.ts b/src/parse.ts index 98f6f62..0ea5708 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,8 +1,9 @@ -import { tokenizeCsv } from './csv/tokenizer'; +import { tokenizeCsv, type CsvRow } from './csv/tokenizer'; import { mapRow, type RawRecord } from './records/rawRecord'; import { DegiroError, type ParseIssue } from './errors'; import { assembleResult, + dialectIssues, resolveDialectRegistry, type ParseOptions, type ParseResult, @@ -10,6 +11,19 @@ import { export type { ParseOptions, ParseResult } from './internal'; +const FALLBACK_DELIMITERS = [';', '\t'] as const; + +function tokenizeRows(input: string, options: ParseOptions): CsvRow[] { + const rows = tokenizeCsv(input, { delimiter: options.delimiter }); + if (options.delimiter !== undefined || (rows[0]?.length ?? 0) > 1) return rows; + + for (const delimiter of FALLBACK_DELIMITERS) { + const retried = tokenizeCsv(input, { delimiter }); + if ((retried[0]?.length ?? 0) > 1) return retried; + } + return rows; +} + /** * Parse the text of a DEGIRO `Account.csv` export into a typed result. * @@ -21,7 +35,7 @@ export type { ParseOptions, ParseResult } from './internal'; * @throws {UnknownDialectError} when no dialect recognises the header. */ export function parseDegiroCsv(input: string, options: ParseOptions = {}): ParseResult { - const rows = tokenizeCsv(input, { delimiter: options.delimiter }); + const rows = tokenizeRows(input, options); if (rows.length === 0) { throw new DegiroError('Cannot parse an empty CSV input'); } @@ -30,7 +44,7 @@ export function parseDegiroCsv(input: string, options: ParseOptions = {}): Parse const dialect = options.dialect ?? resolveDialectRegistry(options.dialects).detect(header); const records: RawRecord[] = []; - const issues: ParseIssue[] = []; + const issues: ParseIssue[] = [...dialectIssues(dialect, header)]; for (let i = 1; i < rows.length; i++) { const result = mapRow(rows[i]!, dialect, i + 1); issues.push(...result.issues); diff --git a/test/classify.test.ts b/test/classify.test.ts index 0f41987..f438d1c 100644 --- a/test/classify.test.ts +++ b/test/classify.test.ts @@ -5,6 +5,7 @@ import { tokenizeCsv, mapRow, frenchDialect, + englishDialect, defaultClassifier, createDefaultClassifierRegistry, type Matcher, @@ -158,6 +159,92 @@ describe('individual matchers', () => { }); }); +describe('English descriptions', () => { + const classify = (rec: RawRecord) => defaultClassifier.classify(rec, englishDialect); + + it('parses a security buy and sell', () => { + const buy = classify( + record('Buy 1,060 SMI ETF@106.02 CHF (CH0019852802)', { isin: 'CH0019852802' }), + ); + expect(buy.kind).toBe('buy'); + if (buy.kind === 'buy') { + expect(buy.quantity).toBe(1060); + expect(buy.unitPrice?.toString()).toBe('106.02 CHF'); + } + expect(classify(record('Sell 10 VWCE@120.36 EUR (IE00BK5BQT80)')).kind).toBe('sell'); + }); + + it('parses FX pair trades and their settlement leg', () => { + const fx = classify(record('Buy 4,800 EUR/CHF@0.9412 CHF ()')); + expect(fx.kind).toBe('fxTrade'); + if (fx.kind === 'fxTrade') { + expect(fx.pair).toBe('EUR/CHF'); + expect(fx.quantity).toBe(4800); + expect(fx.settlement).toBe(false); + } + const settled = classify( + record('Currency transaction settlement: Sell 4,800 EUR/CHF@0.9412 CHF ()'), + ); + if (settled.kind === 'fxTrade') expect(settled.settlement).toBe(true); + }); + + it('distinguishes FX credit and debit legs in both spellings', () => { + expect(classify(record('Currency Exchange - Credit')).kind).toBe('fxCredit'); + expect(classify(record('Currency Exchange (Debit)')).kind).toBe('fxDebit'); + expect(classify(record('FX Credit')).kind).toBe('fxCredit'); + expect(classify(record('FX Debit')).kind).toBe('fxDebit'); + }); + + it('classifies dividend, dividend tax and capital return', () => { + expect(classify(record('Dividend')).kind).toBe('dividend'); + expect(classify(record('Dividend Tax')).kind).toBe('dividendTax'); + expect(classify(record('Return of Capital')).kind).toBe('capitalReturn'); + expect(classify(record('Capital Return')).kind).toBe('capitalReturn'); + }); + + it('classifies fees and extracts the connectivity year', () => { + expect(classify(record('DEGIRO Transaction and/or Third Party Fees')).kind).toBe( + 'brokerageFee', + ); + const conn = classify(record('DEGIRO Exchange Connection Fee 2024 (Euronext Amsterdam - EAM)')); + expect(conn.kind).toBe('connectivityFee'); + if (conn.kind === 'connectivityFee') expect(conn.year).toBe(2024); + expect(classify(record('DEGIRO Exchange Connectivity Fee 2024')).kind).toBe('connectivityFee'); + }); + + it('classifies cash transfers with direction and stated amount', () => { + const out = classify(record('Transfer to your Cash Account at flatex Bank: 6,770.10 CHF')); + expect(out.kind).toBe('cashTransfer'); + if (out.kind === 'cashTransfer') { + expect(out.direction).toBe('toCashAccount'); + expect(out.statedAmount?.toString()).toBe('6770.1 CHF'); + } + const incoming = classify(record('Transfer from your Cash Account at flatex Bank: 213.25 EUR')); + if (incoming.kind === 'cashTransfer') expect(incoming.direction).toBe('fromCashAccount'); + }); + + it('prefers a cash transfer over a deposit when the row names the cash account', () => { + expect(classify(record('Deposit to your Cash Account at flatex Bank: 213.25 EUR')).kind).toBe( + 'cashTransfer', + ); + expect( + classify(record('Withdrawal from your Cash Account at flatex Bank: 213.25 EUR')).kind, + ).toBe('cashTransfer'); + }); + + it('classifies deposits and withdrawals, however they are qualified', () => { + expect(classify(record('Deposit')).kind).toBe('deposit'); + expect(classify(record('flatex Deposit')).kind).toBe('deposit'); + expect(classify(record('Withdrawal')).kind).toBe('withdrawal'); + expect(classify(record('Processed Flatex Withdrawal')).kind).toBe('withdrawal'); + }); + + it('does not mistake a French export read through the English dialect', () => { + expect(classify(record('Versement de fonds')).kind).toBe('deposit'); + expect(classify(record('Degiro Cash Sweep Transfer')).kind).toBe('cashSweep'); + }); +}); + describe('classifier extensibility', () => { it('classifies an otherwise-unknown description via a custom matcher', () => { // A referral bonus is not understood by the built-ins... diff --git a/test/dialect.test.ts b/test/dialect.test.ts index f8b5de1..e77838c 100644 --- a/test/dialect.test.ts +++ b/test/dialect.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect } from 'vitest'; import { frenchDialect, + englishDialect, + genericDialect, + matchesDegiroLayout, + parseFlexibleDateTime, parseFrenchDecimal, + parseFlexibleDecimal, parseFrenchDateTime, DialectRegistry, createDefaultDialectRegistry, @@ -44,6 +49,45 @@ describe('parseFrenchDecimal', () => { }); }); +const englishHeader = [ + 'Date', + 'Time', + 'Value date', + 'Product', + 'ISIN', + 'Description', + 'FX', + 'Change', + '', + 'Balance', + '', + 'Order Id', +]; + +describe('parseFlexibleDecimal', () => { + it('reads US thousands separators with a dot decimal mark', () => { + expect(parseFlexibleDecimal('-1,060.20')).toBe('-1060.20'); + expect(parseFlexibleDecimal('9,000.00')).toBe('9000.00'); + expect(parseFlexibleDecimal('0.9412')).toBe('0.9412'); + }); + + it('reads the European format DEGIRO keeps in English exports', () => { + expect(parseFlexibleDecimal('14\u202f980,01')).toBe('14980.01'); + expect(parseFlexibleDecimal('-2145,60')).toBe('-2145.60'); + expect(parseFlexibleDecimal('1.060,20')).toBe('1060.20'); + }); + + it('treats a repeated separator as grouping', () => { + expect(parseFlexibleDecimal('1,060,200')).toBe('1060200'); + }); + + it('returns null for empty or invalid input', () => { + expect(parseFlexibleDecimal('')).toBeNull(); + expect(parseFlexibleDecimal('n/a')).toBeNull(); + expect(parseFlexibleDecimal('1.060.20')).toBeNull(); + }); +}); + describe('parseFrenchDateTime', () => { it('parses DD-MM-YYYY with HH:MM as UTC', () => { const d = parseFrenchDateTime('01-02-2025', '12:21'); @@ -70,12 +114,102 @@ describe('frenchDialect', () => { }); }); +describe('englishDialect', () => { + it('matches the English header', () => { + expect(englishDialect.matches(englishHeader)).toBe(true); + }); + + it('does not match a French header', () => { + expect(englishDialect.matches(frenchHeader)).toBe(false); + }); + + it('shares the positional layout with the French dialect', () => { + expect(englishDialect.columns).toEqual(frenchDialect.columns); + }); + + it('reads DD-MM-YYYY dates as UTC', () => { + expect(englishDialect.parseDateTime('01-02-2025', '12:21')?.toISOString()).toBe( + '2025-02-01T12:21:00.000Z', + ); + }); +}); + +describe('parseFlexibleDateTime', () => { + it('reads the DEGIRO DD-MM-YYYY format', () => { + expect(parseFlexibleDateTime('01-02-2025', '12:21')?.toISOString()).toBe( + '2025-02-01T12:21:00.000Z', + ); + }); + + it('reads slashed and ISO dates', () => { + expect(parseFlexibleDateTime('01/02/2025')?.toISOString()).toBe('2025-02-01T00:00:00.000Z'); + expect(parseFlexibleDateTime('2025-02-01')?.toISOString()).toBe('2025-02-01T00:00:00.000Z'); + }); + + it('rejects impossible dates', () => { + expect(parseFlexibleDateTime('32-13-2026')).toBeNull(); + expect(parseFlexibleDateTime('2025-02-30')).toBeNull(); + }); +}); + +describe('genericDialect', () => { + const dutchHeader = [ + 'Datum', + 'Tijd', + 'Valutadatum', + 'Product', + 'ISIN', + 'Omschrijving', + 'FX', + 'Mutatie', + '', + 'Saldo', + '', + 'Order Id', + ]; + + it('recognises a header in a language it has never seen', () => { + expect(genericDialect.matches(dutchHeader)).toBe(true); + expect(matchesDegiroLayout(dutchHeader)).toBe(true); + }); + + it('recognises the French and English headers too, being a superset', () => { + expect(genericDialect.matches(frenchHeader)).toBe(true); + expect(genericDialect.matches(englishHeader)).toBe(true); + }); + + it('declares itself heuristic, so parsing can warn about it', () => { + expect(genericDialect.heuristic).toBe(true); + expect(frenchDialect.heuristic).toBeUndefined(); + expect(englishDialect.heuristic).toBeUndefined(); + }); + + it('rejects anything that is not the DEGIRO column layout', () => { + expect(genericDialect.matches(['Date', 'Amount', 'Balance'])).toBe(false); + expect(genericDialect.matches([...dutchHeader, 'Extra'])).toBe(false); + expect(genericDialect.matches(dutchHeader.map((c, i) => (i === 3 ? '' : c)))).toBe(false); + expect(genericDialect.matches(dutchHeader.map((c, i) => (i === 8 ? 'Filled' : c)))).toBe(false); + }); + + it('is matched only after the language-aware dialects', () => { + const registry = createDefaultDialectRegistry(); + expect(registry.detect(frenchHeader).id).toBe('fr'); + expect(registry.detect(englishHeader).id).toBe('en'); + expect(registry.detect(dutchHeader).id).toBe('generic'); + }); +}); + describe('DialectRegistry', () => { it('detects the French dialect from the default registry', () => { const registry = createDefaultDialectRegistry(); expect(registry.detect(frenchHeader).id).toBe('fr'); }); + it('detects the English dialect from the default registry', () => { + const registry = createDefaultDialectRegistry(); + expect(registry.detect(englishHeader).id).toBe('en'); + }); + it('throws UnknownDialectError when nothing matches', () => { const registry = new DialectRegistry(); expect(() => registry.detect(frenchHeader)).toThrow(UnknownDialectError); @@ -90,6 +224,6 @@ describe('DialectRegistry', () => { }; const registry = createDefaultDialectRegistry().register(custom, { prepend: true }); expect(registry.detect(frenchHeader).id).toBe('custom'); - expect(registry.all().map((d) => d.id)).toEqual(['custom', 'fr']); + expect(registry.all().map((d) => d.id)).toEqual(['custom', 'fr', 'en', 'generic']); }); }); diff --git a/test/fixtures/Account-en.csv b/test/fixtures/Account-en.csv new file mode 100644 index 0000000..a3865f7 --- /dev/null +++ b/test/fixtures/Account-en.csv @@ -0,0 +1,237 @@ +Date,Time,Value date,Product,ISIN,Description,FX,Change,,Balance,,Order Id +01-02-2025,12:21,01-02-2025,,,Degiro Cash Sweep Transfer,,EUR,"213,25",EUR,"12680,99", +01-02-2025,12:21,01-02-2025,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 213,25 EUR",,,,EUR,"12467,74", +01-02-2025,12:20,01-02-2025,,,Degiro Cash Sweep Transfer,,CHF,"3601,90",CHF,"32581,78", +01-02-2025,12:20,01-02-2025,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 3 601,9 CHF",,,,CHF,"28979,88", +01-02-2025,12:10,01-02-2025,,,Degiro Cash Sweep Transfer,,CHF,"-6770,10",CHF,"32581,78", +01-02-2025,12:10,01-02-2025,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 6 770,1 CHF",,,,CHF,"39351,88", +01-02-2025,11:42,01-02-2025,ISHARES CORE MSCI WORLD UCITS ETF,IE00B4L5Y983,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,12",EUR,"12680,99",6d3f0c1b-d6e3-4626-a943-88e4d5fbe1f5 +01-02-2025,11:42,01-02-2025,ISHARES CORE MSCI WORLD UCITS ETF,IE00B4L5Y983,"Achat 42 iShares Core MSCI World UCITS ETF USD (Acc)@96,11 CHF (IE00B4L5Y983)",,CHF,"-4036,62",CHF,"32581,78",6d3f0c1b-d6e3-4626-a943-88e4d5fbe1f5 +01-02-2025,11:35,01-02-2025,AMUNDI STOXX EUROPE 600 ETF ACC,LU0908500753,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,99",EUR,"12682,11",b1a5677b-ce87-440d-a586-145e3aead0fb +01-02-2025,11:35,01-02-2025,AMUNDI STOXX EUROPE 600 ETF ACC,LU0908500753,"Achat 250 Amundi STOXX Europe 600 UCITS ETF acc@4,2796 CHF (LU0908500753)",,CHF,"-1069,90",CHF,"36618,40",b1a5677b-ce87-440d-a586-145e3aead0fb +01-02-2025,11:33,01-02-2025,INVESCO MSCI JAPAN UCITS ETF HCHF ACC,IE00B3DWVS88,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-0,95",EUR,"12684,10",56002d4c-82c8-4005-a9c1-7686b0eff5a7 +01-02-2025,11:33,01-02-2025,INVESCO MSCI JAPAN UCITS ETF HCHF ACC,IE00B3DWVS88,"Achat 37 Invesco MSCI Japan UCITS ETF hCHF acc@33,48 CHF (IE00B3DWVS88)",,CHF,"-1238,76",CHF,"37688,30",56002d4c-82c8-4005-a9c1-7686b0eff5a7 +01-02-2025,11:33,01-02-2025,INVESCO MSCI JAPAN UCITS ETF HCHF ACC,IE00B3DWVS88,"Achat 7 Invesco MSCI Japan UCITS ETF hCHF acc@43,12 CHF (IE00B3DWVS88)",,CHF,"-301,84",CHF,"38927,06",56002d4c-82c8-4005-a9c1-7686b0eff5a7 +01-02-2025,11:33,01-02-2025,SPDR MSCI USA HCHF UCITS ETF CHF,IE00BYTRRD19,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,54",EUR,"12685,05",1d1f879a-e68c-4c93-a227-1e363143d3a0 +01-02-2025,11:33,01-02-2025,SPDR MSCI USA HCHF UCITS ETF CHF,IE00BYTRRD19,"Achat 69 SPDR MSCI USA hCHF UCITS ETF CHF acc@47,04 CHF (IE00BYTRRD19)",,CHF,"-3245,76",CHF,"39228,90",1d1f879a-e68c-4c93-a227-1e363143d3a0 +01-02-2025,11:32,01-02-2025,AMUNDI MSCI EMERGING MARKETS,LU1681045370,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,33",EUR,"12686,59",885e4878-c673-4401-abba-7093cadc4712 +01-02-2025,11:32,01-02-2025,AMUNDI MSCI EMERGING MARKETS,LU1681045370,"Achat 13 Amundi MSCI Emerging Markets UCITS 1C ETF@72,56 CHF (LU1681045370)",,CHF,"-943,28",CHF,"42474,66",885e4878-c673-4401-abba-7093cadc4712 +01-02-2025,11:31,01-02-2025,INVESCO NASDAQ-100 UCITS 1C ETF,IE00BYVTMS52,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,34",EUR,"12688,92",2d6225a2-7dd7-4267-a798-3b1155862792 +01-02-2025,11:31,01-02-2025,INVESCO NASDAQ-100 UCITS 1C ETF,IE00BYVTMS52,"Achat 40 Invesco Nasdaq-100 UCITS 1C ETF@50,4 CHF (IE00BYVTMS52)",,CHF,"-2016,00",CHF,"43417,94",2d6225a2-7dd7-4267-a798-3b1155862792 +01-02-2025,11:31,01-02-2025,,,Degiro Cash Sweep Transfer,,EUR,"5254,64",EUR,"12691,26", +01-02-2025,11:31,01-02-2025,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 5 254,64 EUR",,,,EUR,"7436,62", +01-02-2025,10:02,01-02-2025,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-3,01",EUR,"12691,26",923bd622-7a7c-4b58-ac38-512b0fdd3079 +01-02-2025,10:02,01-02-2025,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 17 SPDR MSCI ACWI UCITS ETF USD Dis@128,4 CHF (IE00B44Z5B48)",,CHF,"2182,80",CHF,"45433,94",923bd622-7a7c-4b58-ac38-512b0fdd3079 +01-02-2025,10:02,01-02-2025,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,81",EUR,"12694,27",76e9d035-d4a9-433e-a616-d64c9453d7f6 +01-02-2025,10:02,01-02-2025,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 17 iShares SMI Mid ETF CHF dis@106,02 CHF (CH0019852802)",,CHF,"1802,34",CHF,"43251,14",76e9d035-d4a9-433e-a616-d64c9453d7f6 +03-12-2024,22:21,03-12-2024,,,Degiro Cash Sweep Transfer,,EUR,"-8867,93",EUR,"12696,08", +03-12-2024,22:21,03-12-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 8 867,93 EUR",,,,EUR,"21564,01", +03-12-2024,20:35,03-12-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,30",EUR,"12696,08",f648447f-2b3c-4ecb-a5f9-3e03fb68bfa3 +03-12-2024,20:35,03-12-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,"Vente 191 SPDR S&P 500 Energy Sector UCITS ETF USD (Acc)@9,833 EUR (IE00BWBXM385)",,EUR,"1878,10",EUR,"12697,38",f648447f-2b3c-4ecb-a5f9-3e03fb68bfa3 +03-12-2024,20:34,03-12-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,06",EUR,"10819,28",229e34a0-a739-4336-a209-f89a8da0e707 +03-12-2024,20:34,03-12-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,"Vente 73 iShares Oil Equipment UCITS ETF@28,21 EUR (IE00B0H8QT01)",,EUR,"2059,33",EUR,"10820,34",229e34a0-a739-4336-a209-f89a8da0e707 +24-11-2024,15:23,24-11-2024,,,Degiro Cash Sweep Transfer,,EUR,"-7452,60",EUR,"8761,01", +24-11-2024,15:23,24-11-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 7 452,6 EUR",,,,EUR,"16213,61", +24-11-2024,14:26,24-11-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,31",EUR,"8761,01",60bbc054-61ec-4383-a91f-d956a2e68e69 +24-11-2024,14:26,24-11-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,"Vente 89 SPDR S&P 500 Energy Sector UCITS ETF USD (Acc)@9,606 EUR (IE00BWBXM385)",,EUR,"854,93",EUR,"8763,32",60bbc054-61ec-4383-a91f-d956a2e68e69 +22-11-2024,08:12,16-11-2024,,,Flatex Interest Income,,CHF,"0,00",CHF,"41448,80", +21-11-2024,22:28,16-11-2024,,,Flatex Interest Income,,EUR,"0,00",EUR,"7908,39", +19-11-2024,10:17,19-11-2024,,,Degiro Cash Sweep Transfer,,CHF,"-2617,96",CHF,"41448,80", +19-11-2024,10:17,19-11-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 2 617,96 CHF",,,,CHF,"44066,76", +19-11-2024,07:32,18-11-2024,,,Operation de change - Crédit,,CHF,"13,38",CHF,"41448,80", +19-11-2024,07:32,18-11-2024,,,Opération de change - Débit,"1,0701",USD,"-14,32",USD,"0,00", +18-11-2024,07:41,17-11-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Dividende,,USD,"14,32",USD,"14,32", +04-11-2024,10:42,04-11-2024,,,Degiro Cash Sweep Transfer,,CHF,"-944,82",CHF,"41435,42", +04-11-2024,10:42,04-11-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 944,82 CHF",,,,CHF,"42380,24", +04-11-2024,10:41,04-11-2024,,,Degiro Cash Sweep Transfer,,EUR,"7273,47",EUR,"7908,39", +04-11-2024,10:41,04-11-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 7 273,47 EUR",,,,EUR,"634,92", +04-11-2024,09:28,04-11-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,33",EUR,"7908,39",be791e8a-fa3f-4346-a803-b7d6610c343b +04-11-2024,09:28,04-11-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 17 SPDR MSCI ACWI UCITS ETF USD Dis@106,03 CHF (IE00B44Z5B48)",,CHF,"1802,51",CHF,"41435,42",be791e8a-fa3f-4346-a803-b7d6610c343b +04-11-2024,09:26,04-11-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,58",EUR,"7909,72",13f0ec06-053c-4d21-ae8b-fe616cc5c75a +04-11-2024,09:26,04-11-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 9 iShares SMI Mid ETF CHF dis@118,51 CHF (CH0019852802)",,CHF,"1066,59",CHF,"39632,91",13f0ec06-053c-4d21-ae8b-fe616cc5c75a +22-10-2024,18:52,22-10-2024,,,Degiro Cash Sweep Transfer,,CHF,"-1625,54",CHF,"38566,32", +22-10-2024,18:52,22-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 1 625,54 CHF",,,,CHF,"40191,86", +22-10-2024,18:51,22-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"4819,24",EUR,"7912,30", +22-10-2024,18:51,22-10-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 4 819,24 EUR",,,,EUR,"3093,06", +22-10-2024,17:16,22-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,12",EUR,"7912,30",4176c22a-0555-48fa-a2eb-3cb4244678dd +22-10-2024,17:16,22-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 18 SPDR MSCI ACWI UCITS ETF USD Dis@108,22 CHF (IE00B44Z5B48)",,CHF,"1947,96",CHF,"38566,32",4176c22a-0555-48fa-a2eb-3cb4244678dd +19-10-2024,20:54,19-10-2024,,,Degiro Cash Sweep Transfer,,CHF,"-2882,41",CHF,"36618,36", +19-10-2024,20:54,19-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 2 882,41 CHF",,,,CHF,"39500,77", +19-10-2024,20:52,19-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"-192,13",EUR,"7913,42", +19-10-2024,20:52,19-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 192,13 EUR",,,,EUR,"8105,55", +19-10-2024,16:44,19-10-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-0,97",EUR,"7913,42",db8b71df-b74c-4159-ad11-cb5ce074d9a7 +19-10-2024,16:44,19-10-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,"Vente 35 iShares Oil Equipment UCITS ETF@20,69 EUR (IE00B0H8QT01)",,EUR,"724,15",EUR,"7914,39",db8b71df-b74c-4159-ad11-cb5ce074d9a7 +19-10-2024,16:41,19-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,99",EUR,"7190,24",43e28cbc-9667-44a0-afd1-f5b518372082 +19-10-2024,16:41,19-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 56 SPDR MSCI ACWI UCITS ETF USD Dis@112,51 CHF (IE00B44Z5B48)",,CHF,"6300,56",CHF,"36618,36",43e28cbc-9667-44a0-afd1-f5b518372082 +19-10-2024,16:40,19-10-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,29",EUR,"7193,23",607d7229-8cc0-4630-a408-3fec168e7528 +19-10-2024,16:40,19-10-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 21 iShares SMI Mid ETF CHF dis@120,5 CHF (CH0019852802)",,CHF,"2530,50",CHF,"30317,80",607d7229-8cc0-4630-a408-3fec168e7528 +15-10-2024,11:18,15-10-2024,,,Degiro Cash Sweep Transfer,,CHF,"-6580,72",CHF,"27787,30", +15-10-2024,11:18,15-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 6 580,72 CHF",,,,CHF,"34368,02", +15-10-2024,08:41,14-10-2024,,,Versement de fonds,,CHF,"4500,00",CHF,"27787,30", +12-10-2024,02:34,11-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"385,07",EUR,"7194,52", +12-10-2024,02:34,11-10-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 385,07 EUR",,,,EUR,"6809,45", +11-10-2024,10:43,18-09-2024,,,Frais de connexion aux places boursières 2025 (- - FX),,EUR,"-2,50",EUR,"7194,52", +01-10-2024,14:31,01-10-2024,,,Degiro Cash Sweep Transfer,,CHF,"-2086,73",CHF,"23287,30", +01-10-2024,14:31,01-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 2 086,73 CHF",,,,CHF,"25374,03", +01-10-2024,14:30,01-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"5338,64",EUR,"7197,02", +01-10-2024,14:30,01-10-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 5 338,64 EUR",,,,EUR,"1858,38", +01-10-2024,13:06,01-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,69",EUR,"7197,02",6d988645-592e-4da8-a742-7145e7051cc6 +01-10-2024,13:06,01-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 41 SPDR MSCI ACWI UCITS ETF USD Dis@100,24 CHF (IE00B44Z5B48)",,CHF,"4109,84",CHF,"23287,30",6d988645-592e-4da8-a742-7145e7051cc6 +01-10-2024,10:41,01-10-2024,,,Degiro Cash Sweep Transfer,,CHF,"-5429,40",CHF,"19177,46", +01-10-2024,10:41,01-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 5 429,4 CHF",,,,CHF,"24606,86", +01-10-2024,10:40,01-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"3826,25",EUR,"7199,71", +01-10-2024,10:40,01-10-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 3 826,25 EUR",,,,EUR,"3373,46", +01-10-2024,09:40,01-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"-798,23",EUR,"7199,71", +01-10-2024,09:40,01-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 798,23 EUR",,,,EUR,"7997,94", +01-10-2024,09:01,01-10-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,10",EUR,"7199,71",abe9728d-e993-478b-a938-b9c547473c2f +01-10-2024,09:01,01-10-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 21 iShares SMI Mid ETF CHF dis@97,66 CHF (CH0019852802)",,CHF,"2050,86",CHF,"19177,46",abe9728d-e993-478b-a938-b9c547473c2f +01-10-2024,09:01,01-10-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 4 iShares SMI Mid ETF CHF dis@114,38 CHF (CH0019852802)",,CHF,"457,52",CHF,"17126,60",abe9728d-e993-478b-a938-b9c547473c2f +01-10-2024,08:20,01-10-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,27",EUR,"7200,81",bcbcb400-ff9e-48cf-a1d2-27fbb5ea6213 +01-10-2024,08:20,01-10-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,"Vente 52 iShares Oil Equipment UCITS ETF@25,9 EUR (IE00B0H8QT01)",,EUR,"1346,80",EUR,"7202,08",bcbcb400-ff9e-48cf-a1d2-27fbb5ea6213 +01-10-2024,08:14,01-10-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,05",EUR,"5855,28",36dbaad1-1cb4-4716-af57-53473f2dac59 +01-10-2024,08:14,01-10-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,"Vente 123 SPDR S&P 500 Energy Sector UCITS ETF USD (Acc)@9,977 EUR (IE00BWBXM385)",,EUR,"1227,17",EUR,"5857,33",36dbaad1-1cb4-4716-af57-53473f2dac59 +24-09-2024,09:51,24-09-2024,,,Degiro Cash Sweep Transfer,,CHF,"-538,91",CHF,"16669,08", +24-09-2024,09:51,24-09-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 538,91 CHF",,,,CHF,"17207,99", +24-09-2024,08:38,23-09-2024,,,Versement de fonds,,CHF,"2500,00",CHF,"16669,08", +23-09-2024,11:41,23-09-2024,,,Degiro Cash Sweep Transfer,,CHF,"-4235,96",CHF,"14169,08", +23-09-2024,11:41,23-09-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 4 235,96 CHF",,,,CHF,"18405,04", +23-09-2024,08:38,22-09-2024,,,Versement de fonds,,CHF,"2000,00",CHF,"14169,08", +21-09-2024,11:31,21-09-2024,,,Degiro Cash Sweep Transfer,,CHF,"-5640,83",CHF,"12169,08", +21-09-2024,11:31,21-09-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 5 640,83 CHF",,,,CHF,"17809,91", +21-09-2024,08:40,20-09-2024,,,Versement de fonds,,CHF,"500,00",CHF,"12169,08", +27-08-2024,01:10,20-08-2024,,,Flatex Interest Income,,EUR,"0,00",EUR,"4630,16", +24-08-2024,07:00,25-08-2024,EUR/CHF,,Opération de change - Débit,,CHF,"308,31",CHF,"11669,08",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +24-08-2024,07:00,25-08-2024,EUR/CHF,,"Règlement transaction devise: Vente 4 800 EUR/CHF@0,9412 CHF ()",,CHF,"4517,76",CHF,"11360,77",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +23-08-2024,15:43,23-08-2024,,,Degiro Cash Sweep Transfer,,EUR,"-2831,79",EUR,"4630,16", +23-08-2024,15:43,23-08-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 2 831,79 EUR",,,,EUR,"7461,95", +23-08-2024,14:51,23-08-2024,,,Degiro Cash Sweep Transfer,,CHF,"6083,49",CHF,"6843,01", +23-08-2024,14:51,23-08-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 083,49 CHF",,,,CHF,"759,52", +23-08-2024,14:07,23-08-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,99",EUR,"4630,16",f9dd5b47-62b3-499d-ad5e-6266132dee64 +23-08-2024,14:07,23-08-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,"Achat 540 SPDR S&P 500 Energy Sector UCITS ETF USD (Acc)@10,396 EUR (IE00BWBXM385)",,EUR,"-5613,84",EUR,"4633,15",f9dd5b47-62b3-499d-ad5e-6266132dee64 +23-08-2024,14:04,23-08-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,09",EUR,"10246,99",65f57a6d-fcc4-4148-a572-c8b1b3d0fce0 +23-08-2024,14:04,23-08-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,"Achat 144 iShares Oil Equipment UCITS ETF@27,71 EUR (IE00B0H8QT01)",,EUR,"-3990,24",EUR,"10248,08",65f57a6d-fcc4-4148-a572-c8b1b3d0fce0 +23-08-2024,13:52,23-08-2024,EUR/CHF,,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,58",CHF,"6843,01",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +23-08-2024,13:52,23-08-2024,EUR/CHF,,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-0,92",CHF,"6845,59",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +23-08-2024,13:52,23-08-2024,EUR/CHF,,Operation de change - Crédit,"1,0914",EUR,"0,85",EUR,"14238,32",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +23-08-2024,13:52,23-08-2024,EUR/CHF,,"Achat 4 800 EUR/CHF@0,9412 CHF ()",,CHF,"-4517,76",CHF,"6846,51",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +22-08-2024,13:50,20-08-2024,,,Flatex Interest Income,,CHF,"0,00",CHF,"11364,27", +21-08-2024,07:49,20-08-2024,,,Operation de change - Crédit,,CHF,"13,57",CHF,"11364,27", +21-08-2024,07:49,20-08-2024,,,Opération de change - Débit,"1,1389",USD,"-15,46",USD,"0,00", +20-08-2024,07:47,18-08-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Dividende,,USD,"15,46",USD,"15,46", +28-05-2024,13:10,18-05-2024,,,Flatex Interest Income,,CHF,"0,00",CHF,"11350,70", +27-05-2024,03:10,18-05-2024,,,Flatex Interest Income,,EUR,"0,00",EUR,"14237,47", +21-05-2024,18:43,21-05-2024,,,Degiro Cash Sweep Transfer,,CHF,"-7258,78",CHF,"11350,70", +21-05-2024,18:43,21-05-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 7 258,78 CHF",,,,CHF,"18609,48", +21-05-2024,08:06,20-05-2024,,,Operation de change - Crédit,,CHF,"35,95",CHF,"11350,70", +21-05-2024,08:06,20-05-2024,,,Opération de change - Débit,"1,1857",USD,"-42,62",USD,"0,00", +20-05-2024,07:34,19-05-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Dividende,,USD,"42,62",USD,"42,62", +19-05-2024,13:30,19-05-2024,,,Degiro Cash Sweep Transfer,,CHF,"6837,73",CHF,"11314,75", +19-05-2024,13:30,19-05-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 837,73 CHF",,,,CHF,"4477,02", +19-05-2024,12:33,19-05-2024,,,Degiro Cash Sweep Transfer,,CHF,"-6667,38",CHF,"11314,75", +19-05-2024,12:33,19-05-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 6 667,38 CHF",,,,CHF,"17982,13", +19-05-2024,10:00,19-05-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,01",CHF,"11314,75",8fba068c-e3e0-4be1-a550-0ac437aa6eeb +19-05-2024,10:00,19-05-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 12 iShares SMI Mid ETF CHF dis@105,04 CHF (CH0019852802)",,CHF,"-1260,48",CHF,"11315,76",8fba068c-e3e0-4be1-a550-0ac437aa6eeb +19-05-2024,08:41,18-05-2024,,,Versement de fonds,,CHF,"4500,00",CHF,"12576,24", +28-04-2024,09:02,28-04-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 1 477,82 CHF",,,,CHF,"8076,24", +28-04-2024,09:02,28-04-2024,,,Degiro Cash Sweep Transfer,,CHF,"-1477,82",CHF,"6598,42", +28-04-2024,07:50,27-04-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Remboursement de capital,,CHF,"13,65",CHF,"8076,24", +28-04-2024,07:49,27-04-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Impôts sur dividende,,CHF,"-20,77",CHF,"8062,59", +28-04-2024,07:49,27-04-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Dividende,,CHF,"117,50",CHF,"8083,36", +22-02-2024,20:01,16-02-2024,,,Flatex Interest Income,,CHF,"0,00",CHF,"7965,86", +22-02-2024,06:42,16-02-2024,,,Flatex Interest Income,,EUR,"0,00",EUR,"14237,47", +20-02-2024,12:01,20-02-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 7 137,88 CHF",,,,CHF,"7965,86", +20-02-2024,12:01,20-02-2024,,,Degiro Cash Sweep Transfer,,CHF,"-7137,88",CHF,"827,98", +20-02-2024,07:20,19-02-2024,,,Operation de change - Crédit,,CHF,"105,95",CHF,"7965,86", +20-02-2024,07:20,19-02-2024,,,Opération de change - Débit,"1,0607",USD,"-112,38",USD,"0,00", +19-02-2024,07:26,18-02-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Dividende,,USD,"112,38",USD,"112,38", +06-02-2024,11:20,06-02-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 1 133,99 CHF",,,,CHF,"7859,91", +06-02-2024,11:20,06-02-2024,,,Degiro Cash Sweep Transfer,,CHF,"1133,99",CHF,"8993,90", +06-02-2024,10:20,06-02-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,30",CHF,"7859,91",065791e8-c364-4363-ae7e-2cf97d9d2fa6 +06-02-2024,10:20,06-02-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 63 SPDR MSCI ACWI UCITS ETF USD Dis@112,62 CHF (IE00B44Z5B48)",,CHF,"-7095,06",CHF,"7861,21",065791e8-c364-4363-ae7e-2cf97d9d2fa6 +04-02-2024,17:20,04-02-2024,,,Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 048 CHF,,,,CHF,"14956,27", +04-02-2024,17:20,04-02-2024,,,Degiro Cash Sweep Transfer,,CHF,"6048,00",CHF,"21004,27", +04-02-2024,16:31,04-02-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,93",CHF,"14956,27",4ba56d86-edb9-42ef-a592-41eea17db970 +04-02-2024,16:31,04-02-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 30 SPDR MSCI ACWI UCITS ETF USD Dis@100,49 CHF (IE00B44Z5B48)",,CHF,"-3014,70",CHF,"14958,20",4ba56d86-edb9-42ef-a592-41eea17db970 +04-02-2024,09:32,04-02-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 8 074,55 CHF",,,,CHF,"17972,90", +04-02-2024,09:32,04-02-2024,,,Degiro Cash Sweep Transfer,,CHF,"-8074,55",CHF,"9898,35", +04-02-2024,08:36,03-02-2024,,,Versement de fonds,,CHF,"1000,00",CHF,"17972,90", +25-11-2023,13:40,25-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 1 692,77 CHF",,,,CHF,"16972,90", +25-11-2023,13:40,25-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"-1894,97",CHF,"18665,67", +25-11-2023,12:30,25-11-2023,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 1 894,97 CHF",,,,CHF,"20560,64", +25-11-2023,12:30,25-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"0,00",CHF,"18665,67", +25-11-2023,09:27,25-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,43",CHF,"18665,67",42c4dd48-82d0-4e47-af39-fdc9537c594b +25-11-2023,09:27,25-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 32 iShares SMI Mid ETF CHF dis@125,81 CHF (CH0019852802)",,CHF,"-4025,92",CHF,"18668,10",42c4dd48-82d0-4e47-af39-fdc9537c594b +25-11-2023,09:26,25-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,95",CHF,"22694,02",492acebd-9769-4fcb-a0e8-a3e58717b18e +25-11-2023,09:26,25-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 7 SPDR MSCI ACWI UCITS ETF USD Dis@116,16 CHF (IE00B44Z5B48)",,CHF,"-813,12",CHF,"22696,97",492acebd-9769-4fcb-a0e8-a3e58717b18e +25-11-2023,09:24,25-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,88",CHF,"23510,09",0fd9979d-bf43-4a7d-adbf-384157aeb4a4 +25-11-2023,09:24,25-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 20 SPDR MSCI ACWI UCITS ETF USD Dis@112,55 CHF (IE00B44Z5B48)",,CHF,"-2251,00",CHF,"23512,97",0fd9979d-bf43-4a7d-adbf-384157aeb4a4 +25-11-2023,08:47,24-11-2023,,,Versement de fonds,,CHF,"3500,00",CHF,"25763,97", +25-11-2023,01:45,24-11-2023,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 3 545,8 CHF",,,,CHF,"22263,97", +25-11-2023,01:45,24-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"-3545,80",CHF,"18718,17", +24-11-2023,20:12,24-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 1 317,5 CHF",,,,CHF,"22263,97", +24-11-2023,20:12,24-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"6668,34",CHF,"23581,47", +24-11-2023,19:42,24-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 668,34 CHF",,,,CHF,"16913,13", +24-11-2023,19:42,24-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"-5002,07",CHF,"23581,47", +24-11-2023,19:31,24-11-2023,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 5 002,07 CHF",,,,CHF,"28583,54", +24-11-2023,19:31,24-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"0,00",CHF,"23581,47", +24-11-2023,15:24,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,20",CHF,"23581,47",0fe98554-44a8-4ec7-a93b-d00984457929 +24-11-2023,15:24,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 5 iShares SMI Mid ETF CHF dis@94,04 CHF (CH0019852802)",,CHF,"-470,20",CHF,"23583,67",0fe98554-44a8-4ec7-a93b-d00984457929 +24-11-2023,15:06,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"1,36",CHF,"24053,87",d4eb0f91-9119-4fab-ac7e-8f94b72c3735 +24-11-2023,15:05,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 6 iShares SMI Mid ETF CHF dis@113,33 CHF (CH0019852802)",,CHF,"679,98",CHF,"24052,51",d4eb0f91-9119-4fab-ac7e-8f94b72c3735 +24-11-2023,10:09,24-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,48",CHF,"23372,53",5ed6d51a-0167-4f92-ad48-2c3c1dc628b9 +24-11-2023,10:09,24-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 1 SPDR MSCI ACWI UCITS ETF USD Dis@97,9 CHF (IE00B44Z5B48)",,CHF,"-97,90",CHF,"23375,01",5ed6d51a-0167-4f92-ad48-2c3c1dc628b9 +24-11-2023,09:25,24-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,88",CHF,"23472,91",355d335a-6fbd-40b3-adee-b3d3460f3a62 +24-11-2023,09:25,24-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 7 SPDR MSCI ACWI UCITS ETF USD Dis@113,03 CHF (IE00B44Z5B48)",,CHF,"-791,21",CHF,"23475,79",355d335a-6fbd-40b3-adee-b3d3460f3a62 +24-11-2023,09:23,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,83",CHF,"24267,00",d4eb0f91-9119-4fab-ac7e-8f94b72c3735 +24-11-2023,09:23,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 4 iShares SMI Mid ETF CHF dis@117,93 CHF (CH0019852802)",,CHF,"-471,72",CHF,"24269,83",d4eb0f91-9119-4fab-ac7e-8f94b72c3735 +24-11-2023,08:40,21-11-2023,,,Versement de fonds,,CHF,"4000,00",CHF,"24741,55", +23-11-2023,20:30,17-11-2023,,,Flatex Interest Income,,EUR,"0,00",EUR,"14237,47", +23-11-2023,08:12,17-11-2023,,,Flatex Interest Income,,CHF,"0,00",CHF,"20741,55", +21-11-2023,10:25,21-11-2023,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 2 283,78 CHF",,,,CHF,"20741,55", +21-11-2023,10:25,21-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"-2283,78",CHF,"18457,77", +21-11-2023,07:18,20-11-2023,,,Operation de change - Crédit,,CHF,"80,56",CHF,"20741,55", +21-11-2023,07:18,20-11-2023,,,Opération de change - Débit,"1,0102",USD,"-81,38",USD,"0,00", +20-11-2023,08:20,20-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 31,46 CHF",,,,CHF,"20660,99", +20-11-2023,08:20,20-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"31,46",CHF,"20692,45", +20-11-2023,07:27,19-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Dividende,,USD,"81,38",USD,"81,38", +20-11-2023,06:36,19-11-2023,,,Opération de change - Débit,,CHF,"27,38",CHF,"20660,99", +20-11-2023,06:36,19-11-2023,,,Operation de change - Crédit,"1,046",EUR,"282,56",EUR,"14237,47", +19-11-2023,13:02,17-11-2023,,,Frais de connexion aux places boursières 2024 (Euronext Amsterdam - EAM),,EUR,"-2,50",EUR,"13954,91", +11-11-2023,12:40,11-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 307,4 CHF",,,,CHF,"20633,61", +11-11-2023,12:40,11-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"6307,40",CHF,"26941,01", +11-11-2023,11:40,11-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,38",CHF,"20633,61",e3d2268e-1256-4ff3-a1cc-bd23c07d79f9 +11-11-2023,11:40,11-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 10 iShares SMI Mid ETF CHF dis@110,05 CHF (CH0019852802)",,CHF,"-1100,50",CHF,"20635,99",e3d2268e-1256-4ff3-a1cc-bd23c07d79f9 +11-11-2023,10:40,11-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 7 202,71 CHF",,,,CHF,"21736,49", +11-11-2023,10:40,11-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"7202,71",CHF,"28939,20", +11-11-2023,09:27,11-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,93",CHF,"21736,49",5ccbb862-4964-4f75-af96-83339e51c3b6 +11-11-2023,09:27,11-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 20 SPDR MSCI ACWI UCITS ETF USD Dis@95,66 CHF (IE00B44Z5B48)",,CHF,"-1913,20",CHF,"21738,42",5ccbb862-4964-4f75-af96-83339e51c3b6 +10-11-2023,11:40,10-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 334,34 CHF",,,,CHF,"23651,62", +10-11-2023,11:40,10-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"6334,34",CHF,"29985,96", +10-11-2023,10:36,10-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,16",CHF,"23651,62",fa3782df-f925-453c-ac72-eef6ed46e81f +10-11-2023,10:36,10-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 15 iShares SMI Mid ETF CHF dis@117,82 CHF (CH0019852802)",,CHF,"-1767,30",CHF,"23652,78",fa3782df-f925-453c-ac72-eef6ed46e81f +06-11-2023,10:50,06-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 5 227,31 CHF",,,,CHF,"25420,08", +06-11-2023,10:50,06-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"5227,31",CHF,"30647,39", +06-11-2023,09:01,06-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,30",CHF,"25420,08",4d9a636a-ab2b-4c83-aaff-d9c3d95a2117 +06-11-2023,09:01,06-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 1 iShares SMI Mid ETF CHF dis@124,06 CHF (CH0019852802)",,CHF,"-124,06",CHF,"25421,38",4d9a636a-ab2b-4c83-aaff-d9c3d95a2117 +06-11-2023,09:01,06-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 7 iShares SMI Mid ETF CHF dis@110,45 CHF (CH0019852802)",,CHF,"-773,15",CHF,"25545,44",4d9a636a-ab2b-4c83-aaff-d9c3d95a2117 +05-11-2023,12:50,05-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 149,05 CHF",,,,CHF,"26318,59", +05-11-2023,12:50,05-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"149,05",CHF,"26467,64", +05-11-2023,11:20,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,78",CHF,"26318,59",3a331019-befe-4705-a654-1bbc0b46ac5f +05-11-2023,11:20,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 19 SPDR MSCI ACWI UCITS ETF USD Dis@113,6 CHF (IE00B44Z5B48)",,CHF,"-2158,40",CHF,"26320,37",3a331019-befe-4705-a654-1bbc0b46ac5f +05-11-2023,11:19,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Opération de change - Débit,"1,0576",EUR,"-3979,44",EUR,"13957,41",1d73c9ea-1d6a-4157-aa67-6e7c34c2553d +05-11-2023,11:19,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Operation de change - Crédit,,CHF,"346,74",CHF,"28478,77",1d73c9ea-1d6a-4157-aa67-6e7c34c2553d +05-11-2023,11:19,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,91",CHF,"28132,03",1d73c9ea-1d6a-4157-aa67-6e7c34c2553d +05-11-2023,11:19,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 11 SPDR MSCI ACWI UCITS ETF USD Dis@109,54 EUR (IE00B44Z5B48)",,EUR,"1204,94",EUR,"17936,85",1d73c9ea-1d6a-4157-aa67-6e7c34c2553d +04-11-2023,10:50,04-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 5 190,47 CHF",,,,CHF,"28134,94", +04-11-2023,10:50,04-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"5190,47",CHF,"33325,41", +04-11-2023,09:23,04-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,36",CHF,"28134,94",dd22f039-ec15-4bae-aec1-0189de78e8dd +04-11-2023,09:23,04-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 9 SPDR MSCI ACWI UCITS ETF USD Dis@127,41 CHF (IE00B44Z5B48)",,CHF,"-1146,69",CHF,"28137,30",dd22f039-ec15-4bae-aec1-0189de78e8dd +03-11-2023,10:51,03-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 3 792,31 CHF",,,,CHF,"29283,99", +03-11-2023,10:51,03-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"3792,31",CHF,"33076,30", +03-11-2023,09:57,03-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Operation de change - Crédit,"1,0435",EUR,"3655,94",EUR,"16731,91",9d858849-545b-4134-a90b-6d81cf7422ce +03-11-2023,09:57,03-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Opération de change - Débit,,CHF,"45,17",CHF,"29283,99",9d858849-545b-4134-a90b-6d81cf7422ce +03-11-2023,09:57,03-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,72",CHF,"29238,82",9d858849-545b-4134-a90b-6d81cf7422ce +03-11-2023,09:57,03-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 7 SPDR MSCI ACWI UCITS ETF USD Dis@125,92 EUR (IE00B44Z5B48)",,EUR,"-881,44",EUR,"13075,97",9d858849-545b-4134-a90b-6d81cf7422ce +31-10-2023,09:52,31-10-2023,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 7 894,62 CHF",,,,CHF,"29240,54", +31-10-2023,09:52,31-10-2023,,,Degiro Cash Sweep Transfer,,CHF,"-7894,62",CHF,"21345,92", +31-10-2023,08:37,30-10-2023,,,Versement de fonds,,CHF,"2000,00",CHF,"29240,54", diff --git a/test/integration.test.ts b/test/integration.test.ts index 44e70bf..c59a90f 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -9,6 +9,7 @@ import { parseFrenchDateTime, ClassifierRegistry, Money, + UnknownDialectError, type Dialect, type Matcher, } from '../src/index'; @@ -18,6 +19,11 @@ const fixture = readFileSync( 'utf8', ); +const englishFixture = readFileSync( + fileURLToPath(new URL('./fixtures/Account-en.csv', import.meta.url)), + 'utf8', +); + describe('end-to-end on the real sample export', () => { const result = parseDegiroCsv(fixture); @@ -37,18 +43,204 @@ describe('end-to-end on the real sample export', () => { }); }); +describe('end-to-end on an English-header export', () => { + const result = parseDegiroCsv(englishFixture); + + it('detects the English dialect and parses it cleanly', () => { + expect(result.dialect.id).toBe('en'); + expect(result.errors).toHaveLength(0); + expect(result.records).toHaveLength(236); + expect(result.movements.every((m) => m.kind !== 'unknown')).toBe(true); + expect(reconcileBalances(result.movements).ok).toBe(true); + }); + + it('yields the same movements as the French export of the same account', () => { + const french = parseDegiroCsv(fixture); + const shape = (r: typeof result) => + r.movements.map((m) => ({ + kind: m.kind, + amount: m.amount?.toString() ?? null, + description: m.record.description, + bookingDate: m.record.bookingDate.toISOString(), + })); + expect(shape(result)).toEqual(shape(french)); + }); + + it('summarizes to the same portfolio as the French export', () => { + const english = summarizePortfolio(result.movements); + const french = summarizePortfolio(parseDegiroCsv(fixture).movements); + expect(english.cashByCurrency.map((m) => m.toString())).toEqual( + french.cashByCurrency.map((m) => m.toString()), + ); + expect(english.positions.map((p) => p.isin)).toEqual(french.positions.map((p) => p.isin)); + }); +}); + +describe('an English export written in English, with US number formatting', () => { + const csv = [ + 'Date,Time,Value date,Product,ISIN,Description,FX,Change,,Balance,,Order Id', + '20-11-2024,09:01,20-11-2024,SMI ETF,CH0019852802,"Buy 1,060 SMI ETF@106.02 CHF (CH0019852802)",,CHF,"-112381.20",CHF,"7,939.80",o1', + '20-11-2024,09:01,20-11-2024,SMI ETF,CH0019852802,DEGIRO Transaction and/or Third Party Fees,,CHF,"-2.79",CHF,"7,937.01",o1', + '19-11-2024,09:01,19-11-2024,VWCE,IE00BK5BQT80,"Sell 10 VWCE@120.36 EUR (IE00BK5BQT80)",,EUR,"1,203.60",EUR,"1,203.60",o2', + '18-11-2024,00:00,18-11-2024,VWCE,IE00BK5BQT80,Dividend,,EUR,"12.34",EUR,"12.34",', + '18-11-2024,00:00,18-11-2024,VWCE,IE00BK5BQT80,Dividend Tax,,EUR,"-1.85",EUR,"10.49",', + '17-11-2024,00:00,17-11-2024,,,Currency Exchange - Credit,1.0888,CHF,"500.00",CHF,"500.00",o3', + '17-11-2024,00:00,17-11-2024,,,FX Debit,1.0888,EUR,"-459.22",EUR,"0.00",o3', + '16-11-2024,00:00,16-11-2024,,,Degiro Cash Sweep Transfer,,CHF,"9,000.00",CHF,"9,000.00",', + '16-11-2024,00:00,16-11-2024,,,"Transfer from your Cash Account at flatex Bank: 9,000.00 CHF",,,,CHF,"0.00",', + '15-11-2024,00:00,15-11-2024,,,Deposit,,CHF,"9,000.00",CHF,"9,000.00",', + '14-11-2024,00:00,14-11-2024,,,Processed Flatex Withdrawal,,CHF,"-100.00",CHF,"8,900.00",', + '13-11-2024,00:00,13-11-2024,,,DEGIRO Exchange Connection Fee 2024 (Euronext Amsterdam - EAM),,EUR,"-2.50",EUR,"-2.50",', + '12-11-2024,00:00,12-11-2024,,,Flatex Interest Income,,EUR,"0.42",EUR,"0.42",', + '11-11-2024,00:00,11-11-2024,,,Return of Capital,,EUR,"5.00",EUR,"5.00",', + '10-11-2024,00:00,10-11-2024,,,"Currency transaction settlement: Sell 1,900 EUR/CHF@0.9412 CHF ()",,EUR,"-1,900.00",EUR,"0.00",o4', + '', + ].join('\n'); + + const result = parseDegiroCsv(csv); + + it('classifies every English description with the built-in matchers', () => { + expect(result.dialect.id).toBe('en'); + expect(result.issues).toHaveLength(0); + expect(result.movements.map((m) => m.kind)).toEqual([ + 'buy', + 'brokerageFee', + 'sell', + 'dividend', + 'dividendTax', + 'fxCredit', + 'fxDebit', + 'cashSweep', + 'cashTransfer', + 'deposit', + 'withdrawal', + 'connectivityFee', + 'interest', + 'capitalReturn', + 'fxTrade', + ]); + }); + + it('reads US-grouped quantities and prices', () => { + const buy = result.movements.find((m) => m.kind === 'buy'); + expect(buy?.kind).toBe('buy'); + if (buy?.kind === 'buy') { + expect(buy.quantity).toBe(1060); + expect(buy.unitPrice?.toString()).toBe('106.02 CHF'); + expect(buy.amount?.toString()).toBe('-112381.2 CHF'); + } + }); + + it('reads the direction and stated amount of an English cash transfer', () => { + const transfer = result.movements.find((m) => m.kind === 'cashTransfer'); + expect(transfer?.kind).toBe('cashTransfer'); + if (transfer?.kind === 'cashTransfer') { + expect(transfer.direction).toBe('fromCashAccount'); + expect(transfer.statedAmount?.toString()).toBe('9000 CHF'); + } + }); + + it('reads the settlement leg of an English FX trade', () => { + const fx = result.movements.find((m) => m.kind === 'fxTrade'); + expect(fx?.kind).toBe('fxTrade'); + if (fx?.kind === 'fxTrade') { + expect(fx.settlement).toBe(true); + expect(fx.pair).toBe('EUR/CHF'); + expect(fx.quantity).toBe(1900); + expect(fx.rate?.toString()).toBe('0.9412 CHF'); + } + }); +}); + +describe('an export in a language no dialect knows', () => { + const dutchCsv = [ + 'Datum,Tijd,Valutadatum,Product,ISIN,Omschrijving,FX,Mutatie,,Saldo,,Order Id', + '20-11-2024,09:01,20-11-2024,SMI ETF,CH0019852802,"Koop 1.060 SMI ETF@106,02 CHF (CH0019852802)",,CHF,"-112.381,20",CHF,"7.939,80",o1', + '20-11-2024,09:01,20-11-2024,SMI ETF,CH0019852802,"Verkoop 10 SMI ETF@106,02 CHF (CH0019852802)",,CHF,"1.060,20",CHF,"9.000,00",o2', + '19-11-2024,00:00,19-11-2024,,,"Valuta Creditering","1,0888",CHF,"500,00",CHF,"500,00",o3', + '18-11-2024,00:00,18-11-2024,,,Storting,,CHF,"9.000,00",CHF,"9.000,00",', + '17-11-2024,00:00,17-11-2024,,,"Valutatransactie afwikkeling: Verkoop 1.900 EUR/CHF@0,9412 CHF ()",,EUR,"-1.900,00",EUR,"0,00",o4', + '', + ].join('\n'); + + const result = parseDegiroCsv(dutchCsv); + + it('falls back to the positional layout and reads every row', () => { + expect(result.dialect.id).toBe('generic'); + expect(result.errors).toHaveLength(0); + expect(result.records).toHaveLength(5); + expect(result.records[0]?.bookingDate.toISOString()).toBe('2024-11-20T09:01:00.000Z'); + expect(result.records[0]?.mutation?.toString()).toBe('-112381.2 CHF'); + expect(result.records[3]?.balance?.toString()).toBe('9000 CHF'); + }); + + it('warns that the file was read by layout rather than by language', () => { + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]?.message).toMatch(/column layout/); + expect(result.warnings[0]?.line).toBe(1); + }); + + it('recovers trades from the shape of the description alone', () => { + const buy = result.movements[0]; + expect(buy?.kind).toBe('buy'); + if (buy?.kind === 'buy') { + expect(buy.quantity).toBe(1060); + expect(buy.unitPrice?.toString()).toBe('106.02 CHF'); + expect(buy.isin).toBe('CH0019852802'); + } + + // Same Dutch verb shape, opposite mutation sign. + expect(result.movements[1]?.kind).toBe('sell'); + }); + + it('recovers an FX pair trade and its settlement leg', () => { + const fx = result.movements[4]; + expect(fx?.kind).toBe('fxTrade'); + if (fx?.kind === 'fxTrade') { + expect(fx.pair).toBe('EUR/CHF'); + expect(fx.quantity).toBe(1900); + expect(fx.rate?.toString()).toBe('0.9412 CHF'); + expect(fx.settlement).toBe(true); + } + }); + + it('leaves descriptions it cannot read as unknown, never dropping the row', () => { + expect(result.movements[3]?.kind).toBe('unknown'); + expect(result.movements[3]?.amount?.toString()).toBe('9000 CHF'); + }); +}); + +describe('a semicolon-delimited export', () => { + const csv = [ + 'Datum;Tijd;Valutadatum;Product;ISIN;Omschrijving;FX;Mutatie;;Saldo;;Order Id', + '18-11-2024;00:00;18-11-2024;;;Storting;;CHF;"9.000,00";CHF;"9.000,00";', + '', + ].join('\n'); + + it('retries the delimiter when the header tokenizes to one cell', () => { + const result = parseDegiroCsv(csv); + expect(result.dialect.id).toBe('generic'); + expect(result.records).toHaveLength(1); + expect(result.records[0]?.balance?.toString()).toBe('9000 CHF'); + }); + + it('does not second-guess an explicit delimiter', () => { + expect(() => parseDegiroCsv(csv, { delimiter: ',' })).toThrow(UnknownDialectError); + }); +}); + describe('extensibility: a custom dialect and custom matchers', () => { - // A different locale: English headers, US number format (comma thousands, dot decimal). - const englishDialect: Dialect = { - id: 'en', - label: 'DEGIRO English (custom)', + // A locale libdegiro does not ship: Dutch headers, dot thousands, comma decimals. + const dutchDialect: Dialect = { + id: 'nl', + label: 'DEGIRO Dutch (custom)', columns: frenchDialect.columns, matches: (header) => - ['Date', 'Time', 'Product', 'ISIN', 'Change', 'Balance'].every((t) => + ['Datum', 'Tijd', 'Product', 'ISIN', 'Omschrijving', 'Mutatie', 'Saldo'].every((t) => header.map((c) => c.trim()).includes(t), ), parseDecimal: (raw) => { - const normalized = raw.trim().replace(/,/g, ''); + const normalized = raw.trim().replace(/\./g, '').replace(',', '.'); if (normalized === '') return null; return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null; }, @@ -56,18 +248,18 @@ describe('extensibility: a custom dialect and custom matchers', () => { parseDate: (date) => parseFrenchDateTime(date), }; - const TRADE = /^(Buy|Sell)\s+(\d+)\s+.*@([\d.]+)\s+([A-Z]{3})\s+\(([^)]*)\)$/; - const englishTradeMatcher: Matcher = { - name: 'en-trade', + const TRADE = /^(Koop|Verkoop)\s+(\d+)\s+.*@([\d,]+)\s+([A-Z]{3})\s+\(([^)]*)\)$/; + const dutchTradeMatcher: Matcher = { + name: 'nl-trade', match({ record }) { const m = TRADE.exec(record.description.trim()); if (!m) return null; - const side = m[1] === 'Buy' ? 'buy' : 'sell'; + const side = m[1] === 'Koop' ? 'buy' : 'sell'; return { kind: side, side, quantity: Number(m[2]), - unitPrice: new Money(m[3]!, m[4]!), + unitPrice: new Money(m[3]!.replace(',', '.'), m[4]!), product: record.product, isin: record.isin ?? (m[5] || null), orderId: record.orderId, @@ -76,29 +268,29 @@ describe('extensibility: a custom dialect and custom matchers', () => { }; }, }; - const englishDepositMatcher: Matcher = { - name: 'en-deposit', + const dutchDepositMatcher: Matcher = { + name: 'nl-deposit', match({ record }) { - if (record.description.trim() !== 'Deposit') return null; + if (record.description.trim() !== 'Storting') return null; return { kind: 'deposit', amount: record.mutation, record }; }, }; - const englishCsv = [ - 'Date,Time,Value date,Product,ISIN,Description,FX,Change,,Balance,,Order Id', - '20-11-2024,09:01,20-11-2024,SMI,CH0019852802,"Buy 10 SMI@106.02 CHF (CH0019852802)",,CHF,"-1,060.20",CHF,"7,939.80",o1', - '14-11-2024,08:37,13-11-2024,,,Deposit,,CHF,"9,000.00",CHF,"9,000.00",', + const dutchCsv = [ + 'Datum,Tijd,Valutadatum,Product,ISIN,Omschrijving,FX,Mutatie,,Saldo,,Order Id', + '20-11-2024,09:01,20-11-2024,SMI,CH0019852802,"Koop 10 SMI@106,02 CHF (CH0019852802)",,CHF,"-1.060,20",CHF,"7.939,80",o1', + '14-11-2024,08:37,13-11-2024,,,Storting,,CHF,"9.000,00",CHF,"9.000,00",', '', ].join('\n'); it('parses a foreign-format export via injected dialect + classifier', () => { - const classifier = new ClassifierRegistry([englishTradeMatcher, englishDepositMatcher]); - const result = parseDegiroCsv(englishCsv, { - dialects: [englishDialect], + const classifier = new ClassifierRegistry([dutchTradeMatcher, dutchDepositMatcher]); + const result = parseDegiroCsv(dutchCsv, { + dialects: [dutchDialect], classifier, }); - expect(result.dialect.id).toBe('en'); + expect(result.dialect.id).toBe('nl'); expect(result.movements).toHaveLength(2); const buy = result.movements.find((m) => m.kind === 'buy'); diff --git a/test/redos.test.ts b/test/redos.test.ts new file mode 100644 index 0000000..59f9be8 --- /dev/null +++ b/test/redos.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { defaultClassifier, frenchDialect, type RawRecord } from '../src/index'; + +const REPEAT = 50_000; +const BUDGET_MS = 1000; + +function record(description: string): RawRecord { + return { + bookingDate: new Date('2026-01-01T00:00:00Z'), + valueDate: new Date('2026-01-01T00:00:00Z'), + product: null, + isin: null, + description, + fxRate: null, + mutation: null, + balance: null, + orderId: null, + raw: [], + }; +} + +const cases: readonly (readonly [string, string])[] = [ + ['a quantity trailed by spaces', `Achat 0 ${' '.repeat(REPEAT)}`], + ['a verb trailed by double spaces', `Achat ${' '.repeat(REPEAT)}`], + ['repeated price separators', `Achat a@${'a@a'.repeat(REPEAT)}`], + ['repeated price separators with no verb', `0@${'@a'.repeat(REPEAT)}`], + ['a price tail of nothing but spaces', `Achat 1 X@${' '.repeat(REPEAT)}`], + ['a cash transfer of nothing but spaces', `Virement vers ${' '.repeat(REPEAT)}`], + ['a cash transfer amount of nothing but spaces', `Virement vers x:${' '.repeat(REPEAT)}1 EUR`], + ['an FX conversion trailed by spaces', `FX${' '.repeat(REPEAT)}`], +]; + +describe('hostile descriptions are classified in linear time', () => { + for (const [name, description] of cases) { + it(`survives ${name}`, () => { + const started = performance.now(); + const movement = defaultClassifier.classify(record(description), frenchDialect); + expect(performance.now() - started).toBeLessThan(BUDGET_MS); + expect(movement.kind).toBeTypeOf('string'); + }); + } +});