@gabrielerandelli/minus-tracker 0.5.7 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -199,6 +199,17 @@ var DEGIROParser = class {
199
199
  constructor(snapshot) {
200
200
  this._snapshot = snapshot;
201
201
  }
202
+ /**
203
+ * Parse a DEGIRO Transactions CSV export string.
204
+ *
205
+ * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
206
+ * @returns Array of normalised Transaction objects. Empty if no data rows are found.
207
+ * @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
208
+ * @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
209
+ *
210
+ * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
211
+ * 3 trading days are skipped silently. Inspect `parser.warnings` for details.
212
+ */
202
213
  parse(csv) {
203
214
  this._warningEntries = [];
204
215
  if (typeof csv !== "string" || csv.includes("\0")) {
@@ -322,10 +333,23 @@ function inferTaxYear(transactions) {
322
333
  var Calculator = class {
323
334
  _transactions;
324
335
  _parseWarnings;
336
+ /**
337
+ * @param transactions - Normalised Transaction objects from the CSV import step.
338
+ * @param parseWarnings - Warnings collected during the import step; forwarded into the report.
339
+ */
325
340
  constructor(transactions, parseWarnings) {
326
341
  this._transactions = transactions;
327
342
  this._parseWarnings = parseWarnings ?? [];
328
343
  }
344
+ /**
345
+ * Run LIFO or FIFO lot-matching over the transaction list.
346
+ *
347
+ * @param method - `"LIFO"` or `"FIFO"`.
348
+ * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
349
+ * ECB rates used, and any accumulated warnings.
350
+ * @throws {CalculationError} when a SELL has no matching open buy lots.
351
+ * `error.isin` and `error.date` identify the problematic transaction.
352
+ */
329
353
  calculateGains(method) {
330
354
  const warnings = [...this._parseWarnings];
331
355
  const sorted = [...this._transactions].sort((a, b) => {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/errors.ts","../src/rates/index.ts","../src/parser/warnings.ts","../src/parser/index.ts","../src/calculator/index.ts"],"sourcesContent":["export { DEGIROParser } from \"./parser/index.js\";\nexport { Calculator } from \"./calculator/index.js\";\nexport { ParseError, CalculationError } from \"./errors.js\";\nexport type {\n Transaction,\n MatchedLot,\n GainsReport,\n LotMethod,\n} from \"./types.js\";\n","export class ParseError extends Error {\n readonly code: \"INVALID_CSV\" | \"MISSING_COLUMN\";\n readonly columnName?: string;\n\n constructor(code: \"INVALID_CSV\");\n constructor(code: \"MISSING_COLUMN\", columnName: string);\n constructor(code: ParseError[\"code\"], columnName?: string) {\n const msg =\n code === \"INVALID_CSV\"\n ? \"Invalid CSV: unable to parse\"\n : `Missing required column: ${columnName}`;\n super(msg);\n this.name = \"ParseError\";\n this.code = code;\n this.columnName = columnName;\n }\n}\n\nexport class CalculationError extends Error {\n readonly isin: string;\n readonly date: string;\n\n constructor(isin: string, date: string) {\n super(`No open lots for ISIN ${isin} on ${date}`);\n this.name = \"CalculationError\";\n this.isin = isin;\n this.date = date;\n }\n}\n","import { fileURLToPath } from \"node:url\";\nimport * as path from \"node:path\";\nimport * as fs from \"node:fs\";\n\nexport type RatesSnapshot = Record<string, Record<string, number>>;\n\n// Load the bundled snapshot lazily; returns null if the file is absent (e.g. broken install)\nlet _bundled: RatesSnapshot | null | undefined = undefined;\n\nfunction getBundledSnapshot(): RatesSnapshot | null {\n if (_bundled !== undefined) return _bundled;\n const __dirname = path.dirname(fileURLToPath(import.meta.url));\n const bundledPath = path.join(__dirname, \"../data/ecb-rates.json\");\n try {\n _bundled = JSON.parse(\n fs.readFileSync(bundledPath, \"utf8\"),\n ) as RatesSnapshot;\n } catch {\n _bundled = null;\n }\n return _bundled;\n}\n\nfunction getUserSnapshot(): RatesSnapshot | null {\n const platform = process.platform;\n let configDir: string;\n if (platform === \"win32\") {\n configDir =\n process.env.APPDATA ??\n path.join(process.env.USERPROFILE ?? \"~\", \"AppData\", \"Roaming\");\n } else {\n configDir =\n process.env.XDG_CONFIG_HOME ??\n path.join(process.env.HOME ?? \"~\", \".config\");\n }\n const userPath = path.join(configDir, \"minus-tracker\", \"ecb-rates.json\");\n try {\n return JSON.parse(fs.readFileSync(userPath, \"utf8\")) as RatesSnapshot;\n } catch {\n return null;\n }\n}\n\nexport function getActiveSnapshot(): RatesSnapshot {\n const bundled = getBundledSnapshot();\n const user = getUserSnapshot();\n if (!bundled && !user) {\n throw new Error(\n \"No ECB rates snapshot available. Run `minus-tracker rates --update` to fetch rates.\",\n );\n }\n if (!bundled) return user!;\n if (!user) return bundled;\n // Merge: user entries override bundled for same date keys\n const merged: RatesSnapshot = {};\n for (const ccy of new Set([...Object.keys(bundled), ...Object.keys(user)])) {\n merged[ccy] = { ...(bundled[ccy] ?? {}), ...(user[ccy] ?? {}) };\n }\n return merged;\n}\n\nexport function isSnapshotStale(\n snapshot: RatesSnapshot,\n today?: string,\n): boolean {\n const todayStr = today ?? new Date().toISOString().slice(0, 10);\n let maxDate = \"0000-01-01\";\n for (const dates of Object.values(snapshot)) {\n for (const d of Object.keys(dates)) {\n if (d > maxDate) maxDate = d;\n }\n }\n if (maxDate === \"0000-01-01\") return true;\n const diffDays =\n (new Date(todayStr).getTime() - new Date(maxDate).getTime()) /\n (1000 * 60 * 60 * 24);\n return diffDays >= 7;\n}\n\nfunction subtractDays(isoDate: string, days: number): string {\n const d = new Date(isoDate + \"T00:00:00Z\");\n d.setUTCDate(d.getUTCDate() - days);\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Look up ECB rate for a currency on a given date.\n * Returns 1.0 for EUR. Walks back up to 3 calendar days for weekend/holiday gaps.\n * Returns null if not found within the window.\n *\n * @param currency ISO 4217 currency code\n * @param date YYYY-MM-DD trade date\n * @param snapshot Optional override (used in tests to inject stub data)\n */\nexport function lookupRate(\n currency: string,\n date: string,\n snapshot?: RatesSnapshot,\n): number | null {\n if (currency === \"EUR\") return 1.0;\n\n const s = snapshot ?? getActiveSnapshot();\n const currencyRates = s[currency];\n if (!currencyRates) return null; // unsupported currency\n\n for (let i = 0; i <= 3; i++) {\n const d = i === 0 ? date : subtractDays(date, i);\n if (currencyRates[d] !== undefined) {\n return currencyRates[d];\n }\n }\n return null;\n}\n","export type WarningEntry =\n | { code: \"MISSING_ISIN\"; row: number }\n | { code: \"UNSUPPORTED_CURRENCY\"; row: number; currency: string }\n | { code: \"NO_ECB_RATE\"; row: number; currency: string; date: string }\n | { code: \"QUANTITY_ZERO\"; row: number };\n\nexport function warningToEnglish(w: WarningEntry): string {\n switch (w.code) {\n case \"MISSING_ISIN\":\n return `Row ${w.row}: missing ISIN — skipped`;\n case \"UNSUPPORTED_CURRENCY\":\n return `Row ${w.row}: unsupported currency ${w.currency} — skipped`;\n case \"NO_ECB_RATE\":\n return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} — skipped`;\n case \"QUANTITY_ZERO\":\n return `Row ${w.row}: quantity is 0 — skipped`;\n }\n}\n","import { Transaction } from \"../types.js\";\nimport { ParseError } from \"../errors.js\";\nimport {\n lookupRate,\n getActiveSnapshot,\n RatesSnapshot,\n} from \"../rates/index.js\";\nimport { WarningEntry, warningToEnglish } from \"./warnings.js\";\n\nconst REQUIRED_COLUMNS = [\n \"ISIN\",\n \"Quantity\",\n \"Price\",\n \"Date\",\n \"Local value\",\n \"Local value currency\",\n \"Transaction costs\",\n \"Transaction costs currency\",\n \"Product\",\n] as const;\n\n/**\n * Parse a single CSV line, respecting RFC-4180-style double-quote escaping.\n * Does not support embedded newlines in fields (not needed for DEGIRO exports).\n */\nfunction parseCSVRow(line: string): string[] {\n const fields: string[] = [];\n let i = 0;\n\n while (i <= line.length) {\n // End of line — stop (handles trailing comma by not emitting extra empty field)\n if (i === line.length) break;\n\n if (line[i] === '\"') {\n // Quoted field\n let field = \"\";\n i++; // skip opening quote\n while (i < line.length) {\n if (line[i] === '\"' && i + 1 < line.length && line[i + 1] === '\"') {\n // Escaped double-quote\n field += '\"';\n i += 2;\n } else if (line[i] === '\"') {\n i++; // skip closing quote\n break;\n } else {\n field += line[i++];\n }\n }\n fields.push(field);\n if (i < line.length && line[i] === \",\") i++; // skip delimiter\n } else {\n // Unquoted field\n const start = i;\n while (i < line.length && line[i] !== \",\") i++;\n fields.push(line.slice(start, i));\n if (i < line.length) i++; // skip delimiter\n }\n }\n\n return fields;\n}\n\n/**\n * Parse a full CSV string (CRLF or LF line endings) into a 2-D array of strings.\n */\nfunction parseCSV(input: string): string[][] {\n const normalized = input.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n return normalized.split(\"\\n\").map(parseCSVRow);\n}\n\n/**\n * Convert a DEGIRO date \"DD-MM-YYYY\" to ISO format \"YYYY-MM-DD\".\n */\nfunction parseDate(ddmmyyyy: string): string {\n const [dd, mm, yyyy] = ddmmyyyy.split(\"-\");\n return `${yyyy}-${mm}-${dd}`;\n}\n\nexport class DEGIROParser {\n private _warningEntries: WarningEntry[] = [];\n private _snapshot?: RatesSnapshot;\n\n constructor(snapshot?: RatesSnapshot) {\n this._snapshot = snapshot;\n }\n\n parse(csv: string): Transaction[] {\n this._warningEntries = [];\n\n // Binary content (null bytes) is not valid CSV\n if (typeof csv !== \"string\" || csv.includes(\"\\x00\")) {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n let rows: string[][];\n try {\n rows = parseCSV(csv);\n } catch {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n if (rows.length === 0) {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n // Build column-name → index map from the header row\n const header = rows[0];\n const colIndex: Record<string, number> = {};\n for (let i = 0; i < header.length; i++) {\n colIndex[header[i].trim()] = i;\n }\n\n // Validate that every required column is present\n for (const col of REQUIRED_COLUMNS) {\n if (colIndex[col] === undefined) {\n throw new ParseError(\"MISSING_COLUMN\", col);\n }\n }\n\n // Resolve the rates snapshot once for the whole parse run\n const snapshot: RatesSnapshot = this._snapshot ?? getActiveSnapshot();\n\n const transactions: Transaction[] = [];\n\n for (let r = 1; r < rows.length; r++) {\n const row = rows[r];\n // 1-indexed row number: header = 1, first data row = 2\n const rowIndex = r + 1;\n\n // Skip completely empty rows (including the trailing empty line after a\n // final newline, and rows that are all whitespace)\n if (row.every((cell) => cell.trim() === \"\")) continue;\n\n const get = (col: string): string => (row[colIndex[col]] ?? \"\").trim();\n\n // --- ISIN ---\n const isin = get(\"ISIN\");\n if (!isin) {\n this._warningEntries.push({ code: \"MISSING_ISIN\", row: rowIndex });\n continue;\n }\n\n // --- Quantity ---\n const rawQty = parseFloat(get(\"Quantity\"));\n if (isNaN(rawQty) || rawQty === 0) {\n this._warningEntries.push({ code: \"QUANTITY_ZERO\", row: rowIndex });\n continue;\n }\n\n const type: \"BUY\" | \"SELL\" = rawQty > 0 ? \"BUY\" : \"SELL\";\n const quantity = Math.abs(rawQty);\n\n // --- Date ---\n const isoDate = parseDate(get(\"Date\"));\n\n // --- Currency & FX ---\n const currency = get(\"Local value currency\");\n const rawTotalLocal = parseFloat(get(\"Local value\"));\n const totalLocal = isNaN(rawTotalLocal) ? 0 : rawTotalLocal;\n\n let totalEUR: number;\n let fxRate: number | undefined;\n\n if (currency === \"EUR\") {\n totalEUR = Math.abs(totalLocal);\n fxRate = undefined;\n } else {\n const rate = lookupRate(currency, isoDate, snapshot);\n if (rate === null) {\n if (snapshot[currency] === undefined) {\n this._warningEntries.push({\n code: \"UNSUPPORTED_CURRENCY\",\n row: rowIndex,\n currency,\n });\n } else {\n this._warningEntries.push({\n code: \"NO_ECB_RATE\",\n row: rowIndex,\n currency,\n date: isoDate,\n });\n }\n continue;\n }\n totalEUR = Math.abs(totalLocal) / rate;\n fxRate = rate;\n }\n\n // --- Fees ---\n const rawFees = parseFloat(get(\"Transaction costs\"));\n const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);\n\n // --- Remaining fields ---\n const pricePerUnit = parseFloat(get(\"Price\"));\n const product = get(\"Product\");\n\n transactions.push({\n isin,\n product,\n date: isoDate,\n type,\n quantity,\n pricePerUnit,\n currency,\n totalLocal,\n totalEUR,\n feesEUR,\n fxRate,\n });\n }\n\n return transactions;\n }\n\n get warnings(): string[] {\n return this._warningEntries.map(warningToEnglish);\n }\n\n get warningEntries(): WarningEntry[] {\n return this._warningEntries;\n }\n}\n","import type {\n Transaction,\n MatchedLot,\n GainsReport,\n LotMethod,\n} from \"../types.js\";\nimport { CalculationError } from \"../errors.js\";\n\ninterface Lot {\n date: string;\n quantity: number;\n pricePerUnitEUR: number;\n feesEUR: number;\n originalQty: number;\n fxRate?: number;\n}\n\nfunction roundHalfUp(x: number): number {\n return (Math.sign(x) * Math.round(Math.abs(x) * 100)) / 100;\n}\n\nfunction inferTaxYear(transactions: Transaction[]): {\n year: number;\n multipleYears: boolean;\n} {\n const counts: Record<string, number> = {};\n for (const t of transactions) {\n const y = t.date.slice(0, 4);\n counts[y] = (counts[y] ?? 0) + 1;\n }\n const years = Object.keys(counts);\n if (years.length === 0)\n return { year: new Date().getFullYear(), multipleYears: false };\n const year = parseInt(\n years.reduce((a, b) => (counts[a] >= counts[b] ? a : b)),\n );\n return { year, multipleYears: years.length > 1 };\n}\n\nexport class Calculator {\n private readonly _transactions: Transaction[];\n private readonly _parseWarnings: string[];\n\n constructor(transactions: Transaction[], parseWarnings?: string[]) {\n this._transactions = transactions;\n this._parseWarnings = parseWarnings ?? [];\n }\n\n calculateGains(method: LotMethod): GainsReport {\n const warnings: string[] = [...this._parseWarnings];\n\n // 1. Sort ascending by date; BUY before SELL on same date\n const sorted = [...this._transactions].sort((a, b) => {\n if (a.date < b.date) return -1;\n if (a.date > b.date) return 1;\n // Same date: BUY before SELL\n if (a.type === \"BUY\" && b.type === \"SELL\") return -1;\n if (a.type === \"SELL\" && b.type === \"BUY\") return 1;\n return 0;\n });\n\n // 2. Tax year inference\n const { year: taxYear, multipleYears } = inferTaxYear(sorted);\n if (multipleYears) {\n warnings.push(\n \"CSV contains transactions from multiple years — filter to a single year for accurate reporting.\",\n );\n }\n\n // 3. Lot matching\n const openLots = new Map<string, Lot[]>();\n const matchedLots: MatchedLot[] = [];\n const ratesUsed: Record<string, number> = {};\n\n for (const tx of sorted) {\n // Collect rates used\n if (tx.fxRate !== undefined) {\n ratesUsed[`${tx.currency}:${tx.date}`] = tx.fxRate;\n }\n\n if (tx.type === \"BUY\") {\n const lot: Lot = {\n date: tx.date,\n quantity: tx.quantity,\n pricePerUnitEUR: tx.totalEUR / tx.quantity,\n feesEUR: tx.feesEUR,\n originalQty: tx.quantity,\n fxRate: tx.fxRate,\n };\n if (!openLots.has(tx.isin)) openLots.set(tx.isin, []);\n openLots.get(tx.isin)!.push(lot);\n } else {\n // SELL\n const lots = openLots.get(tx.isin);\n if (!lots || lots.length === 0) {\n throw new CalculationError(tx.isin, tx.date);\n }\n\n const sellPricePerUnitEUR = tx.totalEUR / tx.quantity;\n let remainingSellQty = tx.quantity;\n\n while (remainingSellQty > 0) {\n if (!lots || lots.length === 0) {\n throw new CalculationError(tx.isin, tx.date);\n }\n\n const lot = method === \"LIFO\" ? lots[lots.length - 1] : lots[0];\n const matchedQty = Math.min(lot.quantity, remainingSellQty);\n\n // Fee allocation\n const allocatedBuyFeesEUR =\n lot.feesEUR * (matchedQty / lot.originalQty);\n const allocatedSellFeesEUR = tx.feesEUR * (matchedQty / tx.quantity);\n\n const buyCostEUR =\n lot.pricePerUnitEUR * matchedQty + allocatedBuyFeesEUR;\n const sellProceedsEUR =\n sellPricePerUnitEUR * matchedQty - allocatedSellFeesEUR;\n const gainLossEUR = sellProceedsEUR - buyCostEUR;\n\n matchedLots.push({\n isin: tx.isin,\n product: tx.product,\n quantity: matchedQty,\n buyDate: lot.date,\n sellDate: tx.date,\n buyPriceEUR: lot.pricePerUnitEUR,\n sellPriceEUR: sellPricePerUnitEUR,\n buyCostEUR: roundHalfUp(buyCostEUR),\n sellProceedsEUR: roundHalfUp(sellProceedsEUR),\n gainLossEUR: roundHalfUp(gainLossEUR),\n buyFxRate: lot.fxRate,\n sellFxRate: tx.fxRate,\n });\n\n lot.quantity -= matchedQty;\n remainingSellQty -= matchedQty;\n\n if (lot.quantity <= 0) {\n if (method === \"LIFO\") {\n lots.pop();\n } else {\n lots.shift();\n }\n }\n }\n }\n }\n\n // 4. Aggregate\n let plusvalenze = 0;\n let minusvalenze = 0;\n for (const lot of matchedLots) {\n if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;\n else minusvalenze += Math.abs(lot.gainLossEUR);\n }\n\n return {\n method,\n taxYear,\n plusvalenze: roundHalfUp(plusvalenze),\n minusvalenze: roundHalfUp(minusvalenze),\n netResult: roundHalfUp(plusvalenze - minusvalenze),\n lots: matchedLots,\n ratesUsed,\n warnings,\n generatedAt: new Date().toISOString(),\n };\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/errors.ts","../src/rates/index.ts","../src/parser/warnings.ts","../src/parser/index.ts","../src/calculator/index.ts"],"sourcesContent":["export { DEGIROParser } from \"./parser/index.js\";\nexport { Calculator } from \"./calculator/index.js\";\nexport { ParseError, CalculationError } from \"./errors.js\";\nexport type {\n Transaction,\n MatchedLot,\n GainsReport,\n LotMethod,\n} from \"./types.js\";\n","export class ParseError extends Error {\n readonly code: \"INVALID_CSV\" | \"MISSING_COLUMN\";\n readonly columnName?: string;\n\n constructor(code: \"INVALID_CSV\");\n constructor(code: \"MISSING_COLUMN\", columnName: string);\n constructor(code: ParseError[\"code\"], columnName?: string) {\n const msg =\n code === \"INVALID_CSV\"\n ? \"Invalid CSV: unable to parse\"\n : `Missing required column: ${columnName}`;\n super(msg);\n this.name = \"ParseError\";\n this.code = code;\n this.columnName = columnName;\n }\n}\n\nexport class CalculationError extends Error {\n readonly isin: string;\n readonly date: string;\n\n constructor(isin: string, date: string) {\n super(`No open lots for ISIN ${isin} on ${date}`);\n this.name = \"CalculationError\";\n this.isin = isin;\n this.date = date;\n }\n}\n","import { fileURLToPath } from \"node:url\";\nimport * as path from \"node:path\";\nimport * as fs from \"node:fs\";\n\nexport type RatesSnapshot = Record<string, Record<string, number>>;\n\n// Load the bundled snapshot lazily; returns null if the file is absent (e.g. broken install)\nlet _bundled: RatesSnapshot | null | undefined = undefined;\n\nfunction getBundledSnapshot(): RatesSnapshot | null {\n if (_bundled !== undefined) return _bundled;\n const __dirname = path.dirname(fileURLToPath(import.meta.url));\n const bundledPath = path.join(__dirname, \"../data/ecb-rates.json\");\n try {\n _bundled = JSON.parse(\n fs.readFileSync(bundledPath, \"utf8\"),\n ) as RatesSnapshot;\n } catch {\n _bundled = null;\n }\n return _bundled;\n}\n\nfunction getUserSnapshot(): RatesSnapshot | null {\n const platform = process.platform;\n let configDir: string;\n if (platform === \"win32\") {\n configDir =\n process.env.APPDATA ??\n path.join(process.env.USERPROFILE ?? \"~\", \"AppData\", \"Roaming\");\n } else {\n configDir =\n process.env.XDG_CONFIG_HOME ??\n path.join(process.env.HOME ?? \"~\", \".config\");\n }\n const userPath = path.join(configDir, \"minus-tracker\", \"ecb-rates.json\");\n try {\n return JSON.parse(fs.readFileSync(userPath, \"utf8\")) as RatesSnapshot;\n } catch {\n return null;\n }\n}\n\nexport function getActiveSnapshot(): RatesSnapshot {\n const bundled = getBundledSnapshot();\n const user = getUserSnapshot();\n if (!bundled && !user) {\n throw new Error(\n \"No ECB rates snapshot available. Run `minus-tracker rates --update` to fetch rates.\",\n );\n }\n if (!bundled) return user!;\n if (!user) return bundled;\n // Merge: user entries override bundled for same date keys\n const merged: RatesSnapshot = {};\n for (const ccy of new Set([...Object.keys(bundled), ...Object.keys(user)])) {\n merged[ccy] = { ...(bundled[ccy] ?? {}), ...(user[ccy] ?? {}) };\n }\n return merged;\n}\n\nexport function isSnapshotStale(\n snapshot: RatesSnapshot,\n today?: string,\n): boolean {\n const todayStr = today ?? new Date().toISOString().slice(0, 10);\n let maxDate = \"0000-01-01\";\n for (const dates of Object.values(snapshot)) {\n for (const d of Object.keys(dates)) {\n if (d > maxDate) maxDate = d;\n }\n }\n if (maxDate === \"0000-01-01\") return true;\n const diffDays =\n (new Date(todayStr).getTime() - new Date(maxDate).getTime()) /\n (1000 * 60 * 60 * 24);\n return diffDays >= 7;\n}\n\nfunction subtractDays(isoDate: string, days: number): string {\n const d = new Date(isoDate + \"T00:00:00Z\");\n d.setUTCDate(d.getUTCDate() - days);\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Look up ECB rate for a currency on a given date.\n * Returns 1.0 for EUR. Walks back up to 3 calendar days for weekend/holiday gaps.\n * Returns null if not found within the window.\n *\n * @param currency ISO 4217 currency code\n * @param date YYYY-MM-DD trade date\n * @param snapshot Optional override (used in tests to inject stub data)\n */\nexport function lookupRate(\n currency: string,\n date: string,\n snapshot?: RatesSnapshot,\n): number | null {\n if (currency === \"EUR\") return 1.0;\n\n const s = snapshot ?? getActiveSnapshot();\n const currencyRates = s[currency];\n if (!currencyRates) return null; // unsupported currency\n\n for (let i = 0; i <= 3; i++) {\n const d = i === 0 ? date : subtractDays(date, i);\n if (currencyRates[d] !== undefined) {\n return currencyRates[d];\n }\n }\n return null;\n}\n","export type WarningEntry =\n | { code: \"MISSING_ISIN\"; row: number }\n | { code: \"UNSUPPORTED_CURRENCY\"; row: number; currency: string }\n | { code: \"NO_ECB_RATE\"; row: number; currency: string; date: string }\n | { code: \"QUANTITY_ZERO\"; row: number };\n\nexport function warningToEnglish(w: WarningEntry): string {\n switch (w.code) {\n case \"MISSING_ISIN\":\n return `Row ${w.row}: missing ISIN — skipped`;\n case \"UNSUPPORTED_CURRENCY\":\n return `Row ${w.row}: unsupported currency ${w.currency} — skipped`;\n case \"NO_ECB_RATE\":\n return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} — skipped`;\n case \"QUANTITY_ZERO\":\n return `Row ${w.row}: quantity is 0 — skipped`;\n }\n}\n","import { Transaction } from \"../types.js\";\nimport { ParseError } from \"../errors.js\";\nimport {\n lookupRate,\n getActiveSnapshot,\n RatesSnapshot,\n} from \"../rates/index.js\";\nimport { WarningEntry, warningToEnglish } from \"./warnings.js\";\n\nconst REQUIRED_COLUMNS = [\n \"ISIN\",\n \"Quantity\",\n \"Price\",\n \"Date\",\n \"Local value\",\n \"Local value currency\",\n \"Transaction costs\",\n \"Transaction costs currency\",\n \"Product\",\n] as const;\n\n/**\n * Parse a single CSV line, respecting RFC-4180-style double-quote escaping.\n * Does not support embedded newlines in fields (not needed for DEGIRO exports).\n */\nfunction parseCSVRow(line: string): string[] {\n const fields: string[] = [];\n let i = 0;\n\n while (i <= line.length) {\n // End of line — stop (handles trailing comma by not emitting extra empty field)\n if (i === line.length) break;\n\n if (line[i] === '\"') {\n // Quoted field\n let field = \"\";\n i++; // skip opening quote\n while (i < line.length) {\n if (line[i] === '\"' && i + 1 < line.length && line[i + 1] === '\"') {\n // Escaped double-quote\n field += '\"';\n i += 2;\n } else if (line[i] === '\"') {\n i++; // skip closing quote\n break;\n } else {\n field += line[i++];\n }\n }\n fields.push(field);\n if (i < line.length && line[i] === \",\") i++; // skip delimiter\n } else {\n // Unquoted field\n const start = i;\n while (i < line.length && line[i] !== \",\") i++;\n fields.push(line.slice(start, i));\n if (i < line.length) i++; // skip delimiter\n }\n }\n\n return fields;\n}\n\n/**\n * Parse a full CSV string (CRLF or LF line endings) into a 2-D array of strings.\n */\nfunction parseCSV(input: string): string[][] {\n const normalized = input.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n return normalized.split(\"\\n\").map(parseCSVRow);\n}\n\n/**\n * Convert a DEGIRO date \"DD-MM-YYYY\" to ISO format \"YYYY-MM-DD\".\n */\nfunction parseDate(ddmmyyyy: string): string {\n const [dd, mm, yyyy] = ddmmyyyy.split(\"-\");\n return `${yyyy}-${mm}-${dd}`;\n}\n\n/**\n * Parses a DEGIRO Transactions CSV export into normalised Transaction objects.\n *\n * Export from Activity → Transactions in the DEGIRO UI (not the Account Statement).\n * Supported currencies: EUR, USD, GBP, CHF. ECB historical rates are used for conversion.\n */\nexport class DEGIROParser {\n private _warningEntries: WarningEntry[] = [];\n private _snapshot?: RatesSnapshot;\n\n constructor(snapshot?: RatesSnapshot) {\n this._snapshot = snapshot;\n }\n\n /**\n * Parse a DEGIRO Transactions CSV export string.\n *\n * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.\n * @returns Array of normalised Transaction objects. Empty if no data rows are found.\n * @throws {ParseError} code `\"INVALID_CSV\"` — malformed CSV or binary content.\n * @throws {ParseError} code `\"MISSING_COLUMN\"` (+ `columnName`) — required column absent.\n *\n * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within\n * 3 trading days are skipped silently. Inspect `parser.warnings` for details.\n */\n parse(csv: string): Transaction[] {\n this._warningEntries = [];\n\n // Binary content (null bytes) is not valid CSV\n if (typeof csv !== \"string\" || csv.includes(\"\\x00\")) {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n let rows: string[][];\n try {\n rows = parseCSV(csv);\n } catch {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n if (rows.length === 0) {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n // Build column-name → index map from the header row\n const header = rows[0];\n const colIndex: Record<string, number> = {};\n for (let i = 0; i < header.length; i++) {\n colIndex[header[i].trim()] = i;\n }\n\n // Validate that every required column is present\n for (const col of REQUIRED_COLUMNS) {\n if (colIndex[col] === undefined) {\n throw new ParseError(\"MISSING_COLUMN\", col);\n }\n }\n\n // Resolve the rates snapshot once for the whole parse run\n const snapshot: RatesSnapshot = this._snapshot ?? getActiveSnapshot();\n\n const transactions: Transaction[] = [];\n\n for (let r = 1; r < rows.length; r++) {\n const row = rows[r];\n // 1-indexed row number: header = 1, first data row = 2\n const rowIndex = r + 1;\n\n // Skip completely empty rows (including the trailing empty line after a\n // final newline, and rows that are all whitespace)\n if (row.every((cell) => cell.trim() === \"\")) continue;\n\n const get = (col: string): string => (row[colIndex[col]] ?? \"\").trim();\n\n // --- ISIN ---\n const isin = get(\"ISIN\");\n if (!isin) {\n this._warningEntries.push({ code: \"MISSING_ISIN\", row: rowIndex });\n continue;\n }\n\n // --- Quantity ---\n const rawQty = parseFloat(get(\"Quantity\"));\n if (isNaN(rawQty) || rawQty === 0) {\n this._warningEntries.push({ code: \"QUANTITY_ZERO\", row: rowIndex });\n continue;\n }\n\n const type: \"BUY\" | \"SELL\" = rawQty > 0 ? \"BUY\" : \"SELL\";\n const quantity = Math.abs(rawQty);\n\n // --- Date ---\n const isoDate = parseDate(get(\"Date\"));\n\n // --- Currency & FX ---\n const currency = get(\"Local value currency\");\n const rawTotalLocal = parseFloat(get(\"Local value\"));\n const totalLocal = isNaN(rawTotalLocal) ? 0 : rawTotalLocal;\n\n let totalEUR: number;\n let fxRate: number | undefined;\n\n if (currency === \"EUR\") {\n totalEUR = Math.abs(totalLocal);\n fxRate = undefined;\n } else {\n const rate = lookupRate(currency, isoDate, snapshot);\n if (rate === null) {\n if (snapshot[currency] === undefined) {\n this._warningEntries.push({\n code: \"UNSUPPORTED_CURRENCY\",\n row: rowIndex,\n currency,\n });\n } else {\n this._warningEntries.push({\n code: \"NO_ECB_RATE\",\n row: rowIndex,\n currency,\n date: isoDate,\n });\n }\n continue;\n }\n totalEUR = Math.abs(totalLocal) / rate;\n fxRate = rate;\n }\n\n // --- Fees ---\n const rawFees = parseFloat(get(\"Transaction costs\"));\n const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);\n\n // --- Remaining fields ---\n const pricePerUnit = parseFloat(get(\"Price\"));\n const product = get(\"Product\");\n\n transactions.push({\n isin,\n product,\n date: isoDate,\n type,\n quantity,\n pricePerUnit,\n currency,\n totalLocal,\n totalEUR,\n feesEUR,\n fxRate,\n });\n }\n\n return transactions;\n }\n\n get warnings(): string[] {\n return this._warningEntries.map(warningToEnglish);\n }\n\n get warningEntries(): WarningEntry[] {\n return this._warningEntries;\n }\n}\n","import type {\n Transaction,\n MatchedLot,\n GainsReport,\n LotMethod,\n} from \"../types.js\";\nimport { CalculationError } from \"../errors.js\";\n\ninterface Lot {\n date: string;\n quantity: number;\n pricePerUnitEUR: number;\n feesEUR: number;\n originalQty: number;\n fxRate?: number;\n}\n\nfunction roundHalfUp(x: number): number {\n return (Math.sign(x) * Math.round(Math.abs(x) * 100)) / 100;\n}\n\nfunction inferTaxYear(transactions: Transaction[]): {\n year: number;\n multipleYears: boolean;\n} {\n const counts: Record<string, number> = {};\n for (const t of transactions) {\n const y = t.date.slice(0, 4);\n counts[y] = (counts[y] ?? 0) + 1;\n }\n const years = Object.keys(counts);\n if (years.length === 0)\n return { year: new Date().getFullYear(), multipleYears: false };\n const year = parseInt(\n years.reduce((a, b) => (counts[a] >= counts[b] ? a : b)),\n );\n return { year, multipleYears: years.length > 1 };\n}\n\n/**\n * Computes Italian capital gains and losses from a list of parsed transactions.\n *\n * Uses LIFO or FIFO lot matching and converts all amounts to EUR via ECB rates.\n */\nexport class Calculator {\n private readonly _transactions: Transaction[];\n private readonly _parseWarnings: string[];\n\n /**\n * @param transactions - Normalised Transaction objects from the CSV import step.\n * @param parseWarnings - Warnings collected during the import step; forwarded into the report.\n */\n constructor(transactions: Transaction[], parseWarnings?: string[]) {\n this._transactions = transactions;\n this._parseWarnings = parseWarnings ?? [];\n }\n\n /**\n * Run LIFO or FIFO lot-matching over the transaction list.\n *\n * @param method - `\"LIFO\"` or `\"FIFO\"`.\n * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,\n * ECB rates used, and any accumulated warnings.\n * @throws {CalculationError} when a SELL has no matching open buy lots.\n * `error.isin` and `error.date` identify the problematic transaction.\n */\n calculateGains(method: LotMethod): GainsReport {\n const warnings: string[] = [...this._parseWarnings];\n\n // 1. Sort ascending by date; BUY before SELL on same date\n const sorted = [...this._transactions].sort((a, b) => {\n if (a.date < b.date) return -1;\n if (a.date > b.date) return 1;\n // Same date: BUY before SELL\n if (a.type === \"BUY\" && b.type === \"SELL\") return -1;\n if (a.type === \"SELL\" && b.type === \"BUY\") return 1;\n return 0;\n });\n\n // 2. Tax year inference\n const { year: taxYear, multipleYears } = inferTaxYear(sorted);\n if (multipleYears) {\n warnings.push(\n \"CSV contains transactions from multiple years — filter to a single year for accurate reporting.\",\n );\n }\n\n // 3. Lot matching\n const openLots = new Map<string, Lot[]>();\n const matchedLots: MatchedLot[] = [];\n const ratesUsed: Record<string, number> = {};\n\n for (const tx of sorted) {\n // Collect rates used\n if (tx.fxRate !== undefined) {\n ratesUsed[`${tx.currency}:${tx.date}`] = tx.fxRate;\n }\n\n if (tx.type === \"BUY\") {\n const lot: Lot = {\n date: tx.date,\n quantity: tx.quantity,\n pricePerUnitEUR: tx.totalEUR / tx.quantity,\n feesEUR: tx.feesEUR,\n originalQty: tx.quantity,\n fxRate: tx.fxRate,\n };\n if (!openLots.has(tx.isin)) openLots.set(tx.isin, []);\n openLots.get(tx.isin)!.push(lot);\n } else {\n // SELL\n const lots = openLots.get(tx.isin);\n if (!lots || lots.length === 0) {\n throw new CalculationError(tx.isin, tx.date);\n }\n\n const sellPricePerUnitEUR = tx.totalEUR / tx.quantity;\n let remainingSellQty = tx.quantity;\n\n while (remainingSellQty > 0) {\n if (!lots || lots.length === 0) {\n throw new CalculationError(tx.isin, tx.date);\n }\n\n const lot = method === \"LIFO\" ? lots[lots.length - 1] : lots[0];\n const matchedQty = Math.min(lot.quantity, remainingSellQty);\n\n // Fee allocation\n const allocatedBuyFeesEUR =\n lot.feesEUR * (matchedQty / lot.originalQty);\n const allocatedSellFeesEUR = tx.feesEUR * (matchedQty / tx.quantity);\n\n const buyCostEUR =\n lot.pricePerUnitEUR * matchedQty + allocatedBuyFeesEUR;\n const sellProceedsEUR =\n sellPricePerUnitEUR * matchedQty - allocatedSellFeesEUR;\n const gainLossEUR = sellProceedsEUR - buyCostEUR;\n\n matchedLots.push({\n isin: tx.isin,\n product: tx.product,\n quantity: matchedQty,\n buyDate: lot.date,\n sellDate: tx.date,\n buyPriceEUR: lot.pricePerUnitEUR,\n sellPriceEUR: sellPricePerUnitEUR,\n buyCostEUR: roundHalfUp(buyCostEUR),\n sellProceedsEUR: roundHalfUp(sellProceedsEUR),\n gainLossEUR: roundHalfUp(gainLossEUR),\n buyFxRate: lot.fxRate,\n sellFxRate: tx.fxRate,\n });\n\n lot.quantity -= matchedQty;\n remainingSellQty -= matchedQty;\n\n if (lot.quantity <= 0) {\n if (method === \"LIFO\") {\n lots.pop();\n } else {\n lots.shift();\n }\n }\n }\n }\n }\n\n // 4. Aggregate\n let plusvalenze = 0;\n let minusvalenze = 0;\n for (const lot of matchedLots) {\n if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;\n else minusvalenze += Math.abs(lot.gainLossEUR);\n }\n\n return {\n method,\n taxYear,\n plusvalenze: roundHalfUp(plusvalenze),\n minusvalenze: roundHalfUp(minusvalenze),\n netResult: roundHalfUp(plusvalenze - minusvalenze),\n lots: matchedLots,\n ratesUsed,\n warnings,\n generatedAt: new Date().toISOString(),\n };\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -57,19 +57,54 @@ type WarningEntry = {
57
57
  row: number;
58
58
  };
59
59
 
60
+ /**
61
+ * Parses a DEGIRO Transactions CSV export into normalised Transaction objects.
62
+ *
63
+ * Export from Activity → Transactions in the DEGIRO UI (not the Account Statement).
64
+ * Supported currencies: EUR, USD, GBP, CHF. ECB historical rates are used for conversion.
65
+ */
60
66
  declare class DEGIROParser {
61
67
  private _warningEntries;
62
68
  private _snapshot?;
63
69
  constructor(snapshot?: RatesSnapshot);
70
+ /**
71
+ * Parse a DEGIRO Transactions CSV export string.
72
+ *
73
+ * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
74
+ * @returns Array of normalised Transaction objects. Empty if no data rows are found.
75
+ * @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
76
+ * @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
77
+ *
78
+ * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
79
+ * 3 trading days are skipped silently. Inspect `parser.warnings` for details.
80
+ */
64
81
  parse(csv: string): Transaction[];
65
82
  get warnings(): string[];
66
83
  get warningEntries(): WarningEntry[];
67
84
  }
68
85
 
86
+ /**
87
+ * Computes Italian capital gains and losses from a list of parsed transactions.
88
+ *
89
+ * Uses LIFO or FIFO lot matching and converts all amounts to EUR via ECB rates.
90
+ */
69
91
  declare class Calculator {
70
92
  private readonly _transactions;
71
93
  private readonly _parseWarnings;
94
+ /**
95
+ * @param transactions - Normalised Transaction objects from the CSV import step.
96
+ * @param parseWarnings - Warnings collected during the import step; forwarded into the report.
97
+ */
72
98
  constructor(transactions: Transaction[], parseWarnings?: string[]);
99
+ /**
100
+ * Run LIFO or FIFO lot-matching over the transaction list.
101
+ *
102
+ * @param method - `"LIFO"` or `"FIFO"`.
103
+ * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
104
+ * ECB rates used, and any accumulated warnings.
105
+ * @throws {CalculationError} when a SELL has no matching open buy lots.
106
+ * `error.isin` and `error.date` identify the problematic transaction.
107
+ */
73
108
  calculateGains(method: LotMethod): GainsReport;
74
109
  }
75
110
 
package/dist/index.d.ts CHANGED
@@ -57,19 +57,54 @@ type WarningEntry = {
57
57
  row: number;
58
58
  };
59
59
 
60
+ /**
61
+ * Parses a DEGIRO Transactions CSV export into normalised Transaction objects.
62
+ *
63
+ * Export from Activity → Transactions in the DEGIRO UI (not the Account Statement).
64
+ * Supported currencies: EUR, USD, GBP, CHF. ECB historical rates are used for conversion.
65
+ */
60
66
  declare class DEGIROParser {
61
67
  private _warningEntries;
62
68
  private _snapshot?;
63
69
  constructor(snapshot?: RatesSnapshot);
70
+ /**
71
+ * Parse a DEGIRO Transactions CSV export string.
72
+ *
73
+ * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
74
+ * @returns Array of normalised Transaction objects. Empty if no data rows are found.
75
+ * @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
76
+ * @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
77
+ *
78
+ * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
79
+ * 3 trading days are skipped silently. Inspect `parser.warnings` for details.
80
+ */
64
81
  parse(csv: string): Transaction[];
65
82
  get warnings(): string[];
66
83
  get warningEntries(): WarningEntry[];
67
84
  }
68
85
 
86
+ /**
87
+ * Computes Italian capital gains and losses from a list of parsed transactions.
88
+ *
89
+ * Uses LIFO or FIFO lot matching and converts all amounts to EUR via ECB rates.
90
+ */
69
91
  declare class Calculator {
70
92
  private readonly _transactions;
71
93
  private readonly _parseWarnings;
94
+ /**
95
+ * @param transactions - Normalised Transaction objects from the CSV import step.
96
+ * @param parseWarnings - Warnings collected during the import step; forwarded into the report.
97
+ */
72
98
  constructor(transactions: Transaction[], parseWarnings?: string[]);
99
+ /**
100
+ * Run LIFO or FIFO lot-matching over the transaction list.
101
+ *
102
+ * @param method - `"LIFO"` or `"FIFO"`.
103
+ * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
104
+ * ECB rates used, and any accumulated warnings.
105
+ * @throws {CalculationError} when a SELL has no matching open buy lots.
106
+ * `error.isin` and `error.date` identify the problematic transaction.
107
+ */
73
108
  calculateGains(method: LotMethod): GainsReport;
74
109
  }
75
110
 
package/dist/index.js CHANGED
@@ -159,6 +159,17 @@ var DEGIROParser = class {
159
159
  constructor(snapshot) {
160
160
  this._snapshot = snapshot;
161
161
  }
162
+ /**
163
+ * Parse a DEGIRO Transactions CSV export string.
164
+ *
165
+ * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
166
+ * @returns Array of normalised Transaction objects. Empty if no data rows are found.
167
+ * @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
168
+ * @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
169
+ *
170
+ * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
171
+ * 3 trading days are skipped silently. Inspect `parser.warnings` for details.
172
+ */
162
173
  parse(csv) {
163
174
  this._warningEntries = [];
164
175
  if (typeof csv !== "string" || csv.includes("\0")) {
@@ -282,10 +293,23 @@ function inferTaxYear(transactions) {
282
293
  var Calculator = class {
283
294
  _transactions;
284
295
  _parseWarnings;
296
+ /**
297
+ * @param transactions - Normalised Transaction objects from the CSV import step.
298
+ * @param parseWarnings - Warnings collected during the import step; forwarded into the report.
299
+ */
285
300
  constructor(transactions, parseWarnings) {
286
301
  this._transactions = transactions;
287
302
  this._parseWarnings = parseWarnings ?? [];
288
303
  }
304
+ /**
305
+ * Run LIFO or FIFO lot-matching over the transaction list.
306
+ *
307
+ * @param method - `"LIFO"` or `"FIFO"`.
308
+ * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
309
+ * ECB rates used, and any accumulated warnings.
310
+ * @throws {CalculationError} when a SELL has no matching open buy lots.
311
+ * `error.isin` and `error.date` identify the problematic transaction.
312
+ */
289
313
  calculateGains(method) {
290
314
  const warnings = [...this._parseWarnings];
291
315
  const sorted = [...this._transactions].sort((a, b) => {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/rates/index.ts","../src/parser/warnings.ts","../src/parser/index.ts","../src/calculator/index.ts"],"sourcesContent":["export class ParseError extends Error {\n readonly code: \"INVALID_CSV\" | \"MISSING_COLUMN\";\n readonly columnName?: string;\n\n constructor(code: \"INVALID_CSV\");\n constructor(code: \"MISSING_COLUMN\", columnName: string);\n constructor(code: ParseError[\"code\"], columnName?: string) {\n const msg =\n code === \"INVALID_CSV\"\n ? \"Invalid CSV: unable to parse\"\n : `Missing required column: ${columnName}`;\n super(msg);\n this.name = \"ParseError\";\n this.code = code;\n this.columnName = columnName;\n }\n}\n\nexport class CalculationError extends Error {\n readonly isin: string;\n readonly date: string;\n\n constructor(isin: string, date: string) {\n super(`No open lots for ISIN ${isin} on ${date}`);\n this.name = \"CalculationError\";\n this.isin = isin;\n this.date = date;\n }\n}\n","import { fileURLToPath } from \"node:url\";\nimport * as path from \"node:path\";\nimport * as fs from \"node:fs\";\n\nexport type RatesSnapshot = Record<string, Record<string, number>>;\n\n// Load the bundled snapshot lazily; returns null if the file is absent (e.g. broken install)\nlet _bundled: RatesSnapshot | null | undefined = undefined;\n\nfunction getBundledSnapshot(): RatesSnapshot | null {\n if (_bundled !== undefined) return _bundled;\n const __dirname = path.dirname(fileURLToPath(import.meta.url));\n const bundledPath = path.join(__dirname, \"../data/ecb-rates.json\");\n try {\n _bundled = JSON.parse(\n fs.readFileSync(bundledPath, \"utf8\"),\n ) as RatesSnapshot;\n } catch {\n _bundled = null;\n }\n return _bundled;\n}\n\nfunction getUserSnapshot(): RatesSnapshot | null {\n const platform = process.platform;\n let configDir: string;\n if (platform === \"win32\") {\n configDir =\n process.env.APPDATA ??\n path.join(process.env.USERPROFILE ?? \"~\", \"AppData\", \"Roaming\");\n } else {\n configDir =\n process.env.XDG_CONFIG_HOME ??\n path.join(process.env.HOME ?? \"~\", \".config\");\n }\n const userPath = path.join(configDir, \"minus-tracker\", \"ecb-rates.json\");\n try {\n return JSON.parse(fs.readFileSync(userPath, \"utf8\")) as RatesSnapshot;\n } catch {\n return null;\n }\n}\n\nexport function getActiveSnapshot(): RatesSnapshot {\n const bundled = getBundledSnapshot();\n const user = getUserSnapshot();\n if (!bundled && !user) {\n throw new Error(\n \"No ECB rates snapshot available. Run `minus-tracker rates --update` to fetch rates.\",\n );\n }\n if (!bundled) return user!;\n if (!user) return bundled;\n // Merge: user entries override bundled for same date keys\n const merged: RatesSnapshot = {};\n for (const ccy of new Set([...Object.keys(bundled), ...Object.keys(user)])) {\n merged[ccy] = { ...(bundled[ccy] ?? {}), ...(user[ccy] ?? {}) };\n }\n return merged;\n}\n\nexport function isSnapshotStale(\n snapshot: RatesSnapshot,\n today?: string,\n): boolean {\n const todayStr = today ?? new Date().toISOString().slice(0, 10);\n let maxDate = \"0000-01-01\";\n for (const dates of Object.values(snapshot)) {\n for (const d of Object.keys(dates)) {\n if (d > maxDate) maxDate = d;\n }\n }\n if (maxDate === \"0000-01-01\") return true;\n const diffDays =\n (new Date(todayStr).getTime() - new Date(maxDate).getTime()) /\n (1000 * 60 * 60 * 24);\n return diffDays >= 7;\n}\n\nfunction subtractDays(isoDate: string, days: number): string {\n const d = new Date(isoDate + \"T00:00:00Z\");\n d.setUTCDate(d.getUTCDate() - days);\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Look up ECB rate for a currency on a given date.\n * Returns 1.0 for EUR. Walks back up to 3 calendar days for weekend/holiday gaps.\n * Returns null if not found within the window.\n *\n * @param currency ISO 4217 currency code\n * @param date YYYY-MM-DD trade date\n * @param snapshot Optional override (used in tests to inject stub data)\n */\nexport function lookupRate(\n currency: string,\n date: string,\n snapshot?: RatesSnapshot,\n): number | null {\n if (currency === \"EUR\") return 1.0;\n\n const s = snapshot ?? getActiveSnapshot();\n const currencyRates = s[currency];\n if (!currencyRates) return null; // unsupported currency\n\n for (let i = 0; i <= 3; i++) {\n const d = i === 0 ? date : subtractDays(date, i);\n if (currencyRates[d] !== undefined) {\n return currencyRates[d];\n }\n }\n return null;\n}\n","export type WarningEntry =\n | { code: \"MISSING_ISIN\"; row: number }\n | { code: \"UNSUPPORTED_CURRENCY\"; row: number; currency: string }\n | { code: \"NO_ECB_RATE\"; row: number; currency: string; date: string }\n | { code: \"QUANTITY_ZERO\"; row: number };\n\nexport function warningToEnglish(w: WarningEntry): string {\n switch (w.code) {\n case \"MISSING_ISIN\":\n return `Row ${w.row}: missing ISIN — skipped`;\n case \"UNSUPPORTED_CURRENCY\":\n return `Row ${w.row}: unsupported currency ${w.currency} — skipped`;\n case \"NO_ECB_RATE\":\n return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} — skipped`;\n case \"QUANTITY_ZERO\":\n return `Row ${w.row}: quantity is 0 — skipped`;\n }\n}\n","import { Transaction } from \"../types.js\";\nimport { ParseError } from \"../errors.js\";\nimport {\n lookupRate,\n getActiveSnapshot,\n RatesSnapshot,\n} from \"../rates/index.js\";\nimport { WarningEntry, warningToEnglish } from \"./warnings.js\";\n\nconst REQUIRED_COLUMNS = [\n \"ISIN\",\n \"Quantity\",\n \"Price\",\n \"Date\",\n \"Local value\",\n \"Local value currency\",\n \"Transaction costs\",\n \"Transaction costs currency\",\n \"Product\",\n] as const;\n\n/**\n * Parse a single CSV line, respecting RFC-4180-style double-quote escaping.\n * Does not support embedded newlines in fields (not needed for DEGIRO exports).\n */\nfunction parseCSVRow(line: string): string[] {\n const fields: string[] = [];\n let i = 0;\n\n while (i <= line.length) {\n // End of line — stop (handles trailing comma by not emitting extra empty field)\n if (i === line.length) break;\n\n if (line[i] === '\"') {\n // Quoted field\n let field = \"\";\n i++; // skip opening quote\n while (i < line.length) {\n if (line[i] === '\"' && i + 1 < line.length && line[i + 1] === '\"') {\n // Escaped double-quote\n field += '\"';\n i += 2;\n } else if (line[i] === '\"') {\n i++; // skip closing quote\n break;\n } else {\n field += line[i++];\n }\n }\n fields.push(field);\n if (i < line.length && line[i] === \",\") i++; // skip delimiter\n } else {\n // Unquoted field\n const start = i;\n while (i < line.length && line[i] !== \",\") i++;\n fields.push(line.slice(start, i));\n if (i < line.length) i++; // skip delimiter\n }\n }\n\n return fields;\n}\n\n/**\n * Parse a full CSV string (CRLF or LF line endings) into a 2-D array of strings.\n */\nfunction parseCSV(input: string): string[][] {\n const normalized = input.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n return normalized.split(\"\\n\").map(parseCSVRow);\n}\n\n/**\n * Convert a DEGIRO date \"DD-MM-YYYY\" to ISO format \"YYYY-MM-DD\".\n */\nfunction parseDate(ddmmyyyy: string): string {\n const [dd, mm, yyyy] = ddmmyyyy.split(\"-\");\n return `${yyyy}-${mm}-${dd}`;\n}\n\nexport class DEGIROParser {\n private _warningEntries: WarningEntry[] = [];\n private _snapshot?: RatesSnapshot;\n\n constructor(snapshot?: RatesSnapshot) {\n this._snapshot = snapshot;\n }\n\n parse(csv: string): Transaction[] {\n this._warningEntries = [];\n\n // Binary content (null bytes) is not valid CSV\n if (typeof csv !== \"string\" || csv.includes(\"\\x00\")) {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n let rows: string[][];\n try {\n rows = parseCSV(csv);\n } catch {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n if (rows.length === 0) {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n // Build column-name → index map from the header row\n const header = rows[0];\n const colIndex: Record<string, number> = {};\n for (let i = 0; i < header.length; i++) {\n colIndex[header[i].trim()] = i;\n }\n\n // Validate that every required column is present\n for (const col of REQUIRED_COLUMNS) {\n if (colIndex[col] === undefined) {\n throw new ParseError(\"MISSING_COLUMN\", col);\n }\n }\n\n // Resolve the rates snapshot once for the whole parse run\n const snapshot: RatesSnapshot = this._snapshot ?? getActiveSnapshot();\n\n const transactions: Transaction[] = [];\n\n for (let r = 1; r < rows.length; r++) {\n const row = rows[r];\n // 1-indexed row number: header = 1, first data row = 2\n const rowIndex = r + 1;\n\n // Skip completely empty rows (including the trailing empty line after a\n // final newline, and rows that are all whitespace)\n if (row.every((cell) => cell.trim() === \"\")) continue;\n\n const get = (col: string): string => (row[colIndex[col]] ?? \"\").trim();\n\n // --- ISIN ---\n const isin = get(\"ISIN\");\n if (!isin) {\n this._warningEntries.push({ code: \"MISSING_ISIN\", row: rowIndex });\n continue;\n }\n\n // --- Quantity ---\n const rawQty = parseFloat(get(\"Quantity\"));\n if (isNaN(rawQty) || rawQty === 0) {\n this._warningEntries.push({ code: \"QUANTITY_ZERO\", row: rowIndex });\n continue;\n }\n\n const type: \"BUY\" | \"SELL\" = rawQty > 0 ? \"BUY\" : \"SELL\";\n const quantity = Math.abs(rawQty);\n\n // --- Date ---\n const isoDate = parseDate(get(\"Date\"));\n\n // --- Currency & FX ---\n const currency = get(\"Local value currency\");\n const rawTotalLocal = parseFloat(get(\"Local value\"));\n const totalLocal = isNaN(rawTotalLocal) ? 0 : rawTotalLocal;\n\n let totalEUR: number;\n let fxRate: number | undefined;\n\n if (currency === \"EUR\") {\n totalEUR = Math.abs(totalLocal);\n fxRate = undefined;\n } else {\n const rate = lookupRate(currency, isoDate, snapshot);\n if (rate === null) {\n if (snapshot[currency] === undefined) {\n this._warningEntries.push({\n code: \"UNSUPPORTED_CURRENCY\",\n row: rowIndex,\n currency,\n });\n } else {\n this._warningEntries.push({\n code: \"NO_ECB_RATE\",\n row: rowIndex,\n currency,\n date: isoDate,\n });\n }\n continue;\n }\n totalEUR = Math.abs(totalLocal) / rate;\n fxRate = rate;\n }\n\n // --- Fees ---\n const rawFees = parseFloat(get(\"Transaction costs\"));\n const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);\n\n // --- Remaining fields ---\n const pricePerUnit = parseFloat(get(\"Price\"));\n const product = get(\"Product\");\n\n transactions.push({\n isin,\n product,\n date: isoDate,\n type,\n quantity,\n pricePerUnit,\n currency,\n totalLocal,\n totalEUR,\n feesEUR,\n fxRate,\n });\n }\n\n return transactions;\n }\n\n get warnings(): string[] {\n return this._warningEntries.map(warningToEnglish);\n }\n\n get warningEntries(): WarningEntry[] {\n return this._warningEntries;\n }\n}\n","import type {\n Transaction,\n MatchedLot,\n GainsReport,\n LotMethod,\n} from \"../types.js\";\nimport { CalculationError } from \"../errors.js\";\n\ninterface Lot {\n date: string;\n quantity: number;\n pricePerUnitEUR: number;\n feesEUR: number;\n originalQty: number;\n fxRate?: number;\n}\n\nfunction roundHalfUp(x: number): number {\n return (Math.sign(x) * Math.round(Math.abs(x) * 100)) / 100;\n}\n\nfunction inferTaxYear(transactions: Transaction[]): {\n year: number;\n multipleYears: boolean;\n} {\n const counts: Record<string, number> = {};\n for (const t of transactions) {\n const y = t.date.slice(0, 4);\n counts[y] = (counts[y] ?? 0) + 1;\n }\n const years = Object.keys(counts);\n if (years.length === 0)\n return { year: new Date().getFullYear(), multipleYears: false };\n const year = parseInt(\n years.reduce((a, b) => (counts[a] >= counts[b] ? a : b)),\n );\n return { year, multipleYears: years.length > 1 };\n}\n\nexport class Calculator {\n private readonly _transactions: Transaction[];\n private readonly _parseWarnings: string[];\n\n constructor(transactions: Transaction[], parseWarnings?: string[]) {\n this._transactions = transactions;\n this._parseWarnings = parseWarnings ?? [];\n }\n\n calculateGains(method: LotMethod): GainsReport {\n const warnings: string[] = [...this._parseWarnings];\n\n // 1. Sort ascending by date; BUY before SELL on same date\n const sorted = [...this._transactions].sort((a, b) => {\n if (a.date < b.date) return -1;\n if (a.date > b.date) return 1;\n // Same date: BUY before SELL\n if (a.type === \"BUY\" && b.type === \"SELL\") return -1;\n if (a.type === \"SELL\" && b.type === \"BUY\") return 1;\n return 0;\n });\n\n // 2. Tax year inference\n const { year: taxYear, multipleYears } = inferTaxYear(sorted);\n if (multipleYears) {\n warnings.push(\n \"CSV contains transactions from multiple years — filter to a single year for accurate reporting.\",\n );\n }\n\n // 3. Lot matching\n const openLots = new Map<string, Lot[]>();\n const matchedLots: MatchedLot[] = [];\n const ratesUsed: Record<string, number> = {};\n\n for (const tx of sorted) {\n // Collect rates used\n if (tx.fxRate !== undefined) {\n ratesUsed[`${tx.currency}:${tx.date}`] = tx.fxRate;\n }\n\n if (tx.type === \"BUY\") {\n const lot: Lot = {\n date: tx.date,\n quantity: tx.quantity,\n pricePerUnitEUR: tx.totalEUR / tx.quantity,\n feesEUR: tx.feesEUR,\n originalQty: tx.quantity,\n fxRate: tx.fxRate,\n };\n if (!openLots.has(tx.isin)) openLots.set(tx.isin, []);\n openLots.get(tx.isin)!.push(lot);\n } else {\n // SELL\n const lots = openLots.get(tx.isin);\n if (!lots || lots.length === 0) {\n throw new CalculationError(tx.isin, tx.date);\n }\n\n const sellPricePerUnitEUR = tx.totalEUR / tx.quantity;\n let remainingSellQty = tx.quantity;\n\n while (remainingSellQty > 0) {\n if (!lots || lots.length === 0) {\n throw new CalculationError(tx.isin, tx.date);\n }\n\n const lot = method === \"LIFO\" ? lots[lots.length - 1] : lots[0];\n const matchedQty = Math.min(lot.quantity, remainingSellQty);\n\n // Fee allocation\n const allocatedBuyFeesEUR =\n lot.feesEUR * (matchedQty / lot.originalQty);\n const allocatedSellFeesEUR = tx.feesEUR * (matchedQty / tx.quantity);\n\n const buyCostEUR =\n lot.pricePerUnitEUR * matchedQty + allocatedBuyFeesEUR;\n const sellProceedsEUR =\n sellPricePerUnitEUR * matchedQty - allocatedSellFeesEUR;\n const gainLossEUR = sellProceedsEUR - buyCostEUR;\n\n matchedLots.push({\n isin: tx.isin,\n product: tx.product,\n quantity: matchedQty,\n buyDate: lot.date,\n sellDate: tx.date,\n buyPriceEUR: lot.pricePerUnitEUR,\n sellPriceEUR: sellPricePerUnitEUR,\n buyCostEUR: roundHalfUp(buyCostEUR),\n sellProceedsEUR: roundHalfUp(sellProceedsEUR),\n gainLossEUR: roundHalfUp(gainLossEUR),\n buyFxRate: lot.fxRate,\n sellFxRate: tx.fxRate,\n });\n\n lot.quantity -= matchedQty;\n remainingSellQty -= matchedQty;\n\n if (lot.quantity <= 0) {\n if (method === \"LIFO\") {\n lots.pop();\n } else {\n lots.shift();\n }\n }\n }\n }\n }\n\n // 4. Aggregate\n let plusvalenze = 0;\n let minusvalenze = 0;\n for (const lot of matchedLots) {\n if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;\n else minusvalenze += Math.abs(lot.gainLossEUR);\n }\n\n return {\n method,\n taxYear,\n plusvalenze: roundHalfUp(plusvalenze),\n minusvalenze: roundHalfUp(minusvalenze),\n netResult: roundHalfUp(plusvalenze - minusvalenze),\n lots: matchedLots,\n ratesUsed,\n warnings,\n generatedAt: new Date().toISOString(),\n };\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/errors.ts","../src/rates/index.ts","../src/parser/warnings.ts","../src/parser/index.ts","../src/calculator/index.ts"],"sourcesContent":["export class ParseError extends Error {\n readonly code: \"INVALID_CSV\" | \"MISSING_COLUMN\";\n readonly columnName?: string;\n\n constructor(code: \"INVALID_CSV\");\n constructor(code: \"MISSING_COLUMN\", columnName: string);\n constructor(code: ParseError[\"code\"], columnName?: string) {\n const msg =\n code === \"INVALID_CSV\"\n ? \"Invalid CSV: unable to parse\"\n : `Missing required column: ${columnName}`;\n super(msg);\n this.name = \"ParseError\";\n this.code = code;\n this.columnName = columnName;\n }\n}\n\nexport class CalculationError extends Error {\n readonly isin: string;\n readonly date: string;\n\n constructor(isin: string, date: string) {\n super(`No open lots for ISIN ${isin} on ${date}`);\n this.name = \"CalculationError\";\n this.isin = isin;\n this.date = date;\n }\n}\n","import { fileURLToPath } from \"node:url\";\nimport * as path from \"node:path\";\nimport * as fs from \"node:fs\";\n\nexport type RatesSnapshot = Record<string, Record<string, number>>;\n\n// Load the bundled snapshot lazily; returns null if the file is absent (e.g. broken install)\nlet _bundled: RatesSnapshot | null | undefined = undefined;\n\nfunction getBundledSnapshot(): RatesSnapshot | null {\n if (_bundled !== undefined) return _bundled;\n const __dirname = path.dirname(fileURLToPath(import.meta.url));\n const bundledPath = path.join(__dirname, \"../data/ecb-rates.json\");\n try {\n _bundled = JSON.parse(\n fs.readFileSync(bundledPath, \"utf8\"),\n ) as RatesSnapshot;\n } catch {\n _bundled = null;\n }\n return _bundled;\n}\n\nfunction getUserSnapshot(): RatesSnapshot | null {\n const platform = process.platform;\n let configDir: string;\n if (platform === \"win32\") {\n configDir =\n process.env.APPDATA ??\n path.join(process.env.USERPROFILE ?? \"~\", \"AppData\", \"Roaming\");\n } else {\n configDir =\n process.env.XDG_CONFIG_HOME ??\n path.join(process.env.HOME ?? \"~\", \".config\");\n }\n const userPath = path.join(configDir, \"minus-tracker\", \"ecb-rates.json\");\n try {\n return JSON.parse(fs.readFileSync(userPath, \"utf8\")) as RatesSnapshot;\n } catch {\n return null;\n }\n}\n\nexport function getActiveSnapshot(): RatesSnapshot {\n const bundled = getBundledSnapshot();\n const user = getUserSnapshot();\n if (!bundled && !user) {\n throw new Error(\n \"No ECB rates snapshot available. Run `minus-tracker rates --update` to fetch rates.\",\n );\n }\n if (!bundled) return user!;\n if (!user) return bundled;\n // Merge: user entries override bundled for same date keys\n const merged: RatesSnapshot = {};\n for (const ccy of new Set([...Object.keys(bundled), ...Object.keys(user)])) {\n merged[ccy] = { ...(bundled[ccy] ?? {}), ...(user[ccy] ?? {}) };\n }\n return merged;\n}\n\nexport function isSnapshotStale(\n snapshot: RatesSnapshot,\n today?: string,\n): boolean {\n const todayStr = today ?? new Date().toISOString().slice(0, 10);\n let maxDate = \"0000-01-01\";\n for (const dates of Object.values(snapshot)) {\n for (const d of Object.keys(dates)) {\n if (d > maxDate) maxDate = d;\n }\n }\n if (maxDate === \"0000-01-01\") return true;\n const diffDays =\n (new Date(todayStr).getTime() - new Date(maxDate).getTime()) /\n (1000 * 60 * 60 * 24);\n return diffDays >= 7;\n}\n\nfunction subtractDays(isoDate: string, days: number): string {\n const d = new Date(isoDate + \"T00:00:00Z\");\n d.setUTCDate(d.getUTCDate() - days);\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Look up ECB rate for a currency on a given date.\n * Returns 1.0 for EUR. Walks back up to 3 calendar days for weekend/holiday gaps.\n * Returns null if not found within the window.\n *\n * @param currency ISO 4217 currency code\n * @param date YYYY-MM-DD trade date\n * @param snapshot Optional override (used in tests to inject stub data)\n */\nexport function lookupRate(\n currency: string,\n date: string,\n snapshot?: RatesSnapshot,\n): number | null {\n if (currency === \"EUR\") return 1.0;\n\n const s = snapshot ?? getActiveSnapshot();\n const currencyRates = s[currency];\n if (!currencyRates) return null; // unsupported currency\n\n for (let i = 0; i <= 3; i++) {\n const d = i === 0 ? date : subtractDays(date, i);\n if (currencyRates[d] !== undefined) {\n return currencyRates[d];\n }\n }\n return null;\n}\n","export type WarningEntry =\n | { code: \"MISSING_ISIN\"; row: number }\n | { code: \"UNSUPPORTED_CURRENCY\"; row: number; currency: string }\n | { code: \"NO_ECB_RATE\"; row: number; currency: string; date: string }\n | { code: \"QUANTITY_ZERO\"; row: number };\n\nexport function warningToEnglish(w: WarningEntry): string {\n switch (w.code) {\n case \"MISSING_ISIN\":\n return `Row ${w.row}: missing ISIN — skipped`;\n case \"UNSUPPORTED_CURRENCY\":\n return `Row ${w.row}: unsupported currency ${w.currency} — skipped`;\n case \"NO_ECB_RATE\":\n return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} — skipped`;\n case \"QUANTITY_ZERO\":\n return `Row ${w.row}: quantity is 0 — skipped`;\n }\n}\n","import { Transaction } from \"../types.js\";\nimport { ParseError } from \"../errors.js\";\nimport {\n lookupRate,\n getActiveSnapshot,\n RatesSnapshot,\n} from \"../rates/index.js\";\nimport { WarningEntry, warningToEnglish } from \"./warnings.js\";\n\nconst REQUIRED_COLUMNS = [\n \"ISIN\",\n \"Quantity\",\n \"Price\",\n \"Date\",\n \"Local value\",\n \"Local value currency\",\n \"Transaction costs\",\n \"Transaction costs currency\",\n \"Product\",\n] as const;\n\n/**\n * Parse a single CSV line, respecting RFC-4180-style double-quote escaping.\n * Does not support embedded newlines in fields (not needed for DEGIRO exports).\n */\nfunction parseCSVRow(line: string): string[] {\n const fields: string[] = [];\n let i = 0;\n\n while (i <= line.length) {\n // End of line — stop (handles trailing comma by not emitting extra empty field)\n if (i === line.length) break;\n\n if (line[i] === '\"') {\n // Quoted field\n let field = \"\";\n i++; // skip opening quote\n while (i < line.length) {\n if (line[i] === '\"' && i + 1 < line.length && line[i + 1] === '\"') {\n // Escaped double-quote\n field += '\"';\n i += 2;\n } else if (line[i] === '\"') {\n i++; // skip closing quote\n break;\n } else {\n field += line[i++];\n }\n }\n fields.push(field);\n if (i < line.length && line[i] === \",\") i++; // skip delimiter\n } else {\n // Unquoted field\n const start = i;\n while (i < line.length && line[i] !== \",\") i++;\n fields.push(line.slice(start, i));\n if (i < line.length) i++; // skip delimiter\n }\n }\n\n return fields;\n}\n\n/**\n * Parse a full CSV string (CRLF or LF line endings) into a 2-D array of strings.\n */\nfunction parseCSV(input: string): string[][] {\n const normalized = input.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n return normalized.split(\"\\n\").map(parseCSVRow);\n}\n\n/**\n * Convert a DEGIRO date \"DD-MM-YYYY\" to ISO format \"YYYY-MM-DD\".\n */\nfunction parseDate(ddmmyyyy: string): string {\n const [dd, mm, yyyy] = ddmmyyyy.split(\"-\");\n return `${yyyy}-${mm}-${dd}`;\n}\n\n/**\n * Parses a DEGIRO Transactions CSV export into normalised Transaction objects.\n *\n * Export from Activity → Transactions in the DEGIRO UI (not the Account Statement).\n * Supported currencies: EUR, USD, GBP, CHF. ECB historical rates are used for conversion.\n */\nexport class DEGIROParser {\n private _warningEntries: WarningEntry[] = [];\n private _snapshot?: RatesSnapshot;\n\n constructor(snapshot?: RatesSnapshot) {\n this._snapshot = snapshot;\n }\n\n /**\n * Parse a DEGIRO Transactions CSV export string.\n *\n * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.\n * @returns Array of normalised Transaction objects. Empty if no data rows are found.\n * @throws {ParseError} code `\"INVALID_CSV\"` — malformed CSV or binary content.\n * @throws {ParseError} code `\"MISSING_COLUMN\"` (+ `columnName`) — required column absent.\n *\n * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within\n * 3 trading days are skipped silently. Inspect `parser.warnings` for details.\n */\n parse(csv: string): Transaction[] {\n this._warningEntries = [];\n\n // Binary content (null bytes) is not valid CSV\n if (typeof csv !== \"string\" || csv.includes(\"\\x00\")) {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n let rows: string[][];\n try {\n rows = parseCSV(csv);\n } catch {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n if (rows.length === 0) {\n throw new ParseError(\"INVALID_CSV\");\n }\n\n // Build column-name → index map from the header row\n const header = rows[0];\n const colIndex: Record<string, number> = {};\n for (let i = 0; i < header.length; i++) {\n colIndex[header[i].trim()] = i;\n }\n\n // Validate that every required column is present\n for (const col of REQUIRED_COLUMNS) {\n if (colIndex[col] === undefined) {\n throw new ParseError(\"MISSING_COLUMN\", col);\n }\n }\n\n // Resolve the rates snapshot once for the whole parse run\n const snapshot: RatesSnapshot = this._snapshot ?? getActiveSnapshot();\n\n const transactions: Transaction[] = [];\n\n for (let r = 1; r < rows.length; r++) {\n const row = rows[r];\n // 1-indexed row number: header = 1, first data row = 2\n const rowIndex = r + 1;\n\n // Skip completely empty rows (including the trailing empty line after a\n // final newline, and rows that are all whitespace)\n if (row.every((cell) => cell.trim() === \"\")) continue;\n\n const get = (col: string): string => (row[colIndex[col]] ?? \"\").trim();\n\n // --- ISIN ---\n const isin = get(\"ISIN\");\n if (!isin) {\n this._warningEntries.push({ code: \"MISSING_ISIN\", row: rowIndex });\n continue;\n }\n\n // --- Quantity ---\n const rawQty = parseFloat(get(\"Quantity\"));\n if (isNaN(rawQty) || rawQty === 0) {\n this._warningEntries.push({ code: \"QUANTITY_ZERO\", row: rowIndex });\n continue;\n }\n\n const type: \"BUY\" | \"SELL\" = rawQty > 0 ? \"BUY\" : \"SELL\";\n const quantity = Math.abs(rawQty);\n\n // --- Date ---\n const isoDate = parseDate(get(\"Date\"));\n\n // --- Currency & FX ---\n const currency = get(\"Local value currency\");\n const rawTotalLocal = parseFloat(get(\"Local value\"));\n const totalLocal = isNaN(rawTotalLocal) ? 0 : rawTotalLocal;\n\n let totalEUR: number;\n let fxRate: number | undefined;\n\n if (currency === \"EUR\") {\n totalEUR = Math.abs(totalLocal);\n fxRate = undefined;\n } else {\n const rate = lookupRate(currency, isoDate, snapshot);\n if (rate === null) {\n if (snapshot[currency] === undefined) {\n this._warningEntries.push({\n code: \"UNSUPPORTED_CURRENCY\",\n row: rowIndex,\n currency,\n });\n } else {\n this._warningEntries.push({\n code: \"NO_ECB_RATE\",\n row: rowIndex,\n currency,\n date: isoDate,\n });\n }\n continue;\n }\n totalEUR = Math.abs(totalLocal) / rate;\n fxRate = rate;\n }\n\n // --- Fees ---\n const rawFees = parseFloat(get(\"Transaction costs\"));\n const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);\n\n // --- Remaining fields ---\n const pricePerUnit = parseFloat(get(\"Price\"));\n const product = get(\"Product\");\n\n transactions.push({\n isin,\n product,\n date: isoDate,\n type,\n quantity,\n pricePerUnit,\n currency,\n totalLocal,\n totalEUR,\n feesEUR,\n fxRate,\n });\n }\n\n return transactions;\n }\n\n get warnings(): string[] {\n return this._warningEntries.map(warningToEnglish);\n }\n\n get warningEntries(): WarningEntry[] {\n return this._warningEntries;\n }\n}\n","import type {\n Transaction,\n MatchedLot,\n GainsReport,\n LotMethod,\n} from \"../types.js\";\nimport { CalculationError } from \"../errors.js\";\n\ninterface Lot {\n date: string;\n quantity: number;\n pricePerUnitEUR: number;\n feesEUR: number;\n originalQty: number;\n fxRate?: number;\n}\n\nfunction roundHalfUp(x: number): number {\n return (Math.sign(x) * Math.round(Math.abs(x) * 100)) / 100;\n}\n\nfunction inferTaxYear(transactions: Transaction[]): {\n year: number;\n multipleYears: boolean;\n} {\n const counts: Record<string, number> = {};\n for (const t of transactions) {\n const y = t.date.slice(0, 4);\n counts[y] = (counts[y] ?? 0) + 1;\n }\n const years = Object.keys(counts);\n if (years.length === 0)\n return { year: new Date().getFullYear(), multipleYears: false };\n const year = parseInt(\n years.reduce((a, b) => (counts[a] >= counts[b] ? a : b)),\n );\n return { year, multipleYears: years.length > 1 };\n}\n\n/**\n * Computes Italian capital gains and losses from a list of parsed transactions.\n *\n * Uses LIFO or FIFO lot matching and converts all amounts to EUR via ECB rates.\n */\nexport class Calculator {\n private readonly _transactions: Transaction[];\n private readonly _parseWarnings: string[];\n\n /**\n * @param transactions - Normalised Transaction objects from the CSV import step.\n * @param parseWarnings - Warnings collected during the import step; forwarded into the report.\n */\n constructor(transactions: Transaction[], parseWarnings?: string[]) {\n this._transactions = transactions;\n this._parseWarnings = parseWarnings ?? [];\n }\n\n /**\n * Run LIFO or FIFO lot-matching over the transaction list.\n *\n * @param method - `\"LIFO\"` or `\"FIFO\"`.\n * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,\n * ECB rates used, and any accumulated warnings.\n * @throws {CalculationError} when a SELL has no matching open buy lots.\n * `error.isin` and `error.date` identify the problematic transaction.\n */\n calculateGains(method: LotMethod): GainsReport {\n const warnings: string[] = [...this._parseWarnings];\n\n // 1. Sort ascending by date; BUY before SELL on same date\n const sorted = [...this._transactions].sort((a, b) => {\n if (a.date < b.date) return -1;\n if (a.date > b.date) return 1;\n // Same date: BUY before SELL\n if (a.type === \"BUY\" && b.type === \"SELL\") return -1;\n if (a.type === \"SELL\" && b.type === \"BUY\") return 1;\n return 0;\n });\n\n // 2. Tax year inference\n const { year: taxYear, multipleYears } = inferTaxYear(sorted);\n if (multipleYears) {\n warnings.push(\n \"CSV contains transactions from multiple years — filter to a single year for accurate reporting.\",\n );\n }\n\n // 3. Lot matching\n const openLots = new Map<string, Lot[]>();\n const matchedLots: MatchedLot[] = [];\n const ratesUsed: Record<string, number> = {};\n\n for (const tx of sorted) {\n // Collect rates used\n if (tx.fxRate !== undefined) {\n ratesUsed[`${tx.currency}:${tx.date}`] = tx.fxRate;\n }\n\n if (tx.type === \"BUY\") {\n const lot: Lot = {\n date: tx.date,\n quantity: tx.quantity,\n pricePerUnitEUR: tx.totalEUR / tx.quantity,\n feesEUR: tx.feesEUR,\n originalQty: tx.quantity,\n fxRate: tx.fxRate,\n };\n if (!openLots.has(tx.isin)) openLots.set(tx.isin, []);\n openLots.get(tx.isin)!.push(lot);\n } else {\n // SELL\n const lots = openLots.get(tx.isin);\n if (!lots || lots.length === 0) {\n throw new CalculationError(tx.isin, tx.date);\n }\n\n const sellPricePerUnitEUR = tx.totalEUR / tx.quantity;\n let remainingSellQty = tx.quantity;\n\n while (remainingSellQty > 0) {\n if (!lots || lots.length === 0) {\n throw new CalculationError(tx.isin, tx.date);\n }\n\n const lot = method === \"LIFO\" ? lots[lots.length - 1] : lots[0];\n const matchedQty = Math.min(lot.quantity, remainingSellQty);\n\n // Fee allocation\n const allocatedBuyFeesEUR =\n lot.feesEUR * (matchedQty / lot.originalQty);\n const allocatedSellFeesEUR = tx.feesEUR * (matchedQty / tx.quantity);\n\n const buyCostEUR =\n lot.pricePerUnitEUR * matchedQty + allocatedBuyFeesEUR;\n const sellProceedsEUR =\n sellPricePerUnitEUR * matchedQty - allocatedSellFeesEUR;\n const gainLossEUR = sellProceedsEUR - buyCostEUR;\n\n matchedLots.push({\n isin: tx.isin,\n product: tx.product,\n quantity: matchedQty,\n buyDate: lot.date,\n sellDate: tx.date,\n buyPriceEUR: lot.pricePerUnitEUR,\n sellPriceEUR: sellPricePerUnitEUR,\n buyCostEUR: roundHalfUp(buyCostEUR),\n sellProceedsEUR: roundHalfUp(sellProceedsEUR),\n gainLossEUR: roundHalfUp(gainLossEUR),\n buyFxRate: lot.fxRate,\n sellFxRate: tx.fxRate,\n });\n\n lot.quantity -= matchedQty;\n remainingSellQty -= matchedQty;\n\n if (lot.quantity <= 0) {\n if (method === \"LIFO\") {\n lots.pop();\n } else {\n lots.shift();\n }\n }\n }\n }\n }\n\n // 4. Aggregate\n let plusvalenze = 0;\n let minusvalenze = 0;\n for (const lot of matchedLots) {\n if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;\n else minusvalenze += Math.abs(lot.gainLossEUR);\n }\n\n return {\n method,\n taxYear,\n plusvalenze: roundHalfUp(plusvalenze),\n minusvalenze: roundHalfUp(minusvalenze),\n netResult: roundHalfUp(plusvalenze - minusvalenze),\n lots: matchedLots,\n ratesUsed,\n warnings,\n generatedAt: new Date().toISOString(),\n };\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gabrielerandelli/minus-tracker",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "description": "Italian capital-gains/loss calculator for the Regime Dichiarativo",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,6 +28,23 @@
28
28
  "vitest": "^2.0.0"
29
29
  },
30
30
  "license": "MIT",
31
+ "keywords": [
32
+ "capital-gains",
33
+ "plusvalenze",
34
+ "minusvalenze",
35
+ "regime-dichiarativo",
36
+ "degiro",
37
+ "tax",
38
+ "italy",
39
+ "csv",
40
+ "lifo",
41
+ "fifo",
42
+ "typescript"
43
+ ],
44
+ "homepage": "https://github.com/gabrielerandelli/minus-tracker#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/gabrielerandelli/minus-tracker/issues"
47
+ },
31
48
  "engines": {
32
49
  "node": ">=24.0.0"
33
50
  },