@gabrielerandelli/minus-tracker 0.5.1

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.
@@ -0,0 +1,863 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { parseArgs } from "util";
5
+
6
+ // src/i18n/it.ts
7
+ var it = {
8
+ numberLocale: "it-IT",
9
+ errorInvalidCsv: "CSV non valido: impossibile analizzare il file",
10
+ errorMissingColumn: (col) => `Colonna obbligatoria mancante: ${col}`,
11
+ errorNoOpenLots: (isin, date) => `Nessun lotto aperto per ISIN ${isin} in data ${date}`,
12
+ warnMissingIsin: (row) => `Riga ${row}: ISIN mancante \u2014 riga ignorata`,
13
+ warnUnsupportedCurrency: (row, currency) => `Riga ${row}: valuta non supportata ${currency} \u2014 riga ignorata`,
14
+ warnNoEcbRate: (row, currency, date) => `Riga ${row}: nessun tasso BCE per ${currency} in data ${date} \u2014 riga ignorata`,
15
+ warnQuantityZero: (row) => `Riga ${row}: quantit\xE0 pari a 0 \u2014 riga ignorata`,
16
+ warnMultipleYears: "Il CSV contiene transazioni di pi\xF9 anni \u2014 filtra per un singolo anno per un calcolo accurato.",
17
+ headerMethod: "METODO",
18
+ headerTaxYear: "ANNO FISCALE",
19
+ headerIsin: "ISIN",
20
+ headerProduct: "TITOLO",
21
+ headerQty: "QT\xC0",
22
+ headerBuyDate: "DATA ACQUISTO",
23
+ headerSellDate: "DATA VENDITA",
24
+ headerBuyEur: "ACQUISTO EUR",
25
+ headerSellEur: "VENDITA EUR",
26
+ headerGainLoss: "GUADAGNO/PERDITA",
27
+ summaryPlusvalenze: "PLUSVALENZE",
28
+ summaryMinusvalenze: "MINUSVALENZE",
29
+ summaryNetResult: "RISULTATO NETTO",
30
+ summaryWarnings: "AVVERTENZE",
31
+ summaryGenerated: "Generato",
32
+ validateOk: (count, errors) => `OK: ${count} transazioni analizzate, ${errors} errori gravi`,
33
+ validateWarn: (count, reason) => `AVVISO: ${count} righe ignorate (${reason})`,
34
+ ratesCoverage: (start, end, currencies) => `Copertura: ${start} \u2192 ${end} | Valute: ${currencies}`,
35
+ ratesGapsNone: "Lacune: nessuna",
36
+ ratesGaps: (list) => `Lacune: ${list}`,
37
+ ratesUpdateFetching: "Recupero dati BCE SDMX in corso...",
38
+ ratesUpdateDone: (n) => `Completato. Aggiunte ${n} nuove date.`,
39
+ ratesSnapshotWritten: (path4) => `Snapshot scritto in ${path4}`,
40
+ configLangSet: (lang) => `Lingua impostata su: ${lang}`,
41
+ configCurrentLang: (lang) => `Lingua corrente: ${lang}`,
42
+ disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale."
43
+ };
44
+
45
+ // src/i18n/en.ts
46
+ var en = {
47
+ numberLocale: "en-US",
48
+ errorInvalidCsv: "Invalid CSV: unable to parse",
49
+ errorMissingColumn: (col) => `Missing required column: ${col}`,
50
+ errorNoOpenLots: (isin, date) => `No open lots for ISIN ${isin} on ${date}`,
51
+ warnMissingIsin: (row) => `Row ${row}: missing ISIN \u2014 skipped`,
52
+ warnUnsupportedCurrency: (row, currency) => `Row ${row}: unsupported currency ${currency} \u2014 skipped`,
53
+ warnNoEcbRate: (row, currency, date) => `Row ${row}: no ECB rate for ${currency} on ${date} \u2014 skipped`,
54
+ warnQuantityZero: (row) => `Row ${row}: quantity is 0 \u2014 skipped`,
55
+ warnMultipleYears: "CSV contains transactions from multiple years \u2014 filter to a single year for accurate reporting.",
56
+ headerMethod: "METHOD",
57
+ headerTaxYear: "TAX YEAR",
58
+ headerIsin: "ISIN",
59
+ headerProduct: "PRODUCT",
60
+ headerQty: "QTY",
61
+ headerBuyDate: "BUY DATE",
62
+ headerSellDate: "SELL DATE",
63
+ headerBuyEur: "BUY EUR",
64
+ headerSellEur: "SELL EUR",
65
+ headerGainLoss: "GAIN/LOSS",
66
+ summaryPlusvalenze: "PLUSVALENZE",
67
+ summaryMinusvalenze: "MINUSVALENZE",
68
+ summaryNetResult: "NET RESULT",
69
+ summaryWarnings: "WARNINGS",
70
+ summaryGenerated: "Generated",
71
+ validateOk: (count, errors) => `OK: ${count} transactions parsed, ${errors} hard errors`,
72
+ validateWarn: (count, reason) => `WARN: ${count} rows skipped (${reason})`,
73
+ ratesCoverage: (start, end, currencies) => `Coverage: ${start} \u2192 ${end} | Currencies: ${currencies}`,
74
+ ratesGapsNone: "Gaps: none",
75
+ ratesGaps: (list) => `Gaps: ${list}`,
76
+ ratesUpdateFetching: "Fetching ECB SDMX...",
77
+ ratesUpdateDone: (n) => `Done. Added ${n} new dates.`,
78
+ ratesSnapshotWritten: (path4) => `Snapshot written to ${path4}`,
79
+ configLangSet: (lang) => `Language set to: ${lang}`,
80
+ configCurrentLang: (lang) => `Current language: ${lang}`,
81
+ disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale."
82
+ };
83
+
84
+ // src/i18n/settings.ts
85
+ import * as fs from "fs";
86
+ import * as os from "os";
87
+ import * as path from "path";
88
+ var SUPPORTED_LOCALES = ["it", "en"];
89
+ function getConfigPath() {
90
+ if (process.platform === "win32") {
91
+ const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
92
+ return path.join(appData, "minus-tracker", "config.json");
93
+ }
94
+ const xdg = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
95
+ return path.join(xdg, "minus-tracker", "config.json");
96
+ }
97
+ function readConfig() {
98
+ try {
99
+ const raw = fs.readFileSync(getConfigPath(), "utf8");
100
+ return JSON.parse(raw);
101
+ } catch {
102
+ return {};
103
+ }
104
+ }
105
+ function saveLocale(lang) {
106
+ const configPath = getConfigPath();
107
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
108
+ const existing = readConfig();
109
+ const updated = { ...existing, locale: lang };
110
+ fs.writeFileSync(configPath, JSON.stringify(updated, null, 2) + "\n", "utf8");
111
+ }
112
+ function resolveLocale(cliLang) {
113
+ if (cliLang !== void 0) {
114
+ if (!SUPPORTED_LOCALES.includes(cliLang)) {
115
+ process.stderr.write(`Unsupported locale "${cliLang}". Use: it, en
116
+ `);
117
+ process.exit(2);
118
+ }
119
+ return cliLang;
120
+ }
121
+ const envLang = process.env.MINUS_TRACKER_LANG;
122
+ if (envLang !== void 0) {
123
+ if (!SUPPORTED_LOCALES.includes(envLang)) {
124
+ process.stderr.write(`Unsupported locale "${envLang}". Use: it, en
125
+ `);
126
+ process.exit(2);
127
+ }
128
+ return envLang;
129
+ }
130
+ const config = readConfig();
131
+ if (config.locale && SUPPORTED_LOCALES.includes(config.locale)) {
132
+ return config.locale;
133
+ }
134
+ return "it";
135
+ }
136
+
137
+ // src/i18n/index.ts
138
+ function getStrings(locale) {
139
+ return locale === "it" ? it : en;
140
+ }
141
+
142
+ // src/errors.ts
143
+ var ParseError = class extends Error {
144
+ code;
145
+ columnName;
146
+ constructor(code, columnName) {
147
+ const msg = code === "INVALID_CSV" ? "Invalid CSV: unable to parse" : `Missing required column: ${columnName}`;
148
+ super(msg);
149
+ this.name = "ParseError";
150
+ this.code = code;
151
+ this.columnName = columnName;
152
+ }
153
+ };
154
+ var CalculationError = class extends Error {
155
+ isin;
156
+ date;
157
+ constructor(isin, date) {
158
+ super(`No open lots for ISIN ${isin} on ${date}`);
159
+ this.name = "CalculationError";
160
+ this.isin = isin;
161
+ this.date = date;
162
+ }
163
+ };
164
+
165
+ // src/cli/commands/calc.ts
166
+ import * as fs3 from "fs";
167
+
168
+ // src/rates/index.ts
169
+ import { fileURLToPath } from "url";
170
+ import * as path2 from "path";
171
+ import * as fs2 from "fs";
172
+ var _bundled = null;
173
+ function getBundledSnapshot() {
174
+ if (_bundled) return _bundled;
175
+ const __dirname = path2.dirname(fileURLToPath(import.meta.url));
176
+ const bundledPath = path2.join(__dirname, "../data/ecb-rates.json");
177
+ _bundled = JSON.parse(fs2.readFileSync(bundledPath, "utf8"));
178
+ return _bundled;
179
+ }
180
+ function getUserSnapshot() {
181
+ const platform = process.platform;
182
+ let configDir;
183
+ if (platform === "win32") {
184
+ configDir = process.env.APPDATA ?? path2.join(process.env.USERPROFILE ?? "~", "AppData", "Roaming");
185
+ } else {
186
+ configDir = process.env.XDG_CONFIG_HOME ?? path2.join(process.env.HOME ?? "~", ".config");
187
+ }
188
+ const userPath = path2.join(configDir, "minus-tracker", "ecb-rates.json");
189
+ try {
190
+ return JSON.parse(fs2.readFileSync(userPath, "utf8"));
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+ function getActiveSnapshot() {
196
+ const bundled = getBundledSnapshot();
197
+ const user = getUserSnapshot();
198
+ if (!user) return bundled;
199
+ const merged = {};
200
+ for (const ccy of /* @__PURE__ */ new Set([...Object.keys(bundled), ...Object.keys(user)])) {
201
+ merged[ccy] = { ...bundled[ccy] ?? {}, ...user[ccy] ?? {} };
202
+ }
203
+ return merged;
204
+ }
205
+ function subtractDays(isoDate, days) {
206
+ const d = /* @__PURE__ */ new Date(isoDate + "T00:00:00Z");
207
+ d.setUTCDate(d.getUTCDate() - days);
208
+ return d.toISOString().slice(0, 10);
209
+ }
210
+ function lookupRate(currency, date, snapshot) {
211
+ if (currency === "EUR") return 1;
212
+ const s = snapshot ?? getActiveSnapshot();
213
+ const currencyRates = s[currency];
214
+ if (!currencyRates) return null;
215
+ for (let i = 0; i <= 3; i++) {
216
+ const d = i === 0 ? date : subtractDays(date, i);
217
+ if (currencyRates[d] !== void 0) {
218
+ return currencyRates[d];
219
+ }
220
+ }
221
+ return null;
222
+ }
223
+
224
+ // src/parser/warnings.ts
225
+ function warningToEnglish(w) {
226
+ switch (w.code) {
227
+ case "MISSING_ISIN":
228
+ return `Row ${w.row}: missing ISIN \u2014 skipped`;
229
+ case "UNSUPPORTED_CURRENCY":
230
+ return `Row ${w.row}: unsupported currency ${w.currency} \u2014 skipped`;
231
+ case "NO_ECB_RATE":
232
+ return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
233
+ case "QUANTITY_ZERO":
234
+ return `Row ${w.row}: quantity is 0 \u2014 skipped`;
235
+ }
236
+ }
237
+
238
+ // src/parser/index.ts
239
+ var REQUIRED_COLUMNS = [
240
+ "ISIN",
241
+ "Quantity",
242
+ "Price",
243
+ "Date",
244
+ "Local value",
245
+ "Local value currency",
246
+ "Transaction costs",
247
+ "Transaction costs currency",
248
+ "Product"
249
+ ];
250
+ function parseCSVRow(line) {
251
+ const fields = [];
252
+ let i = 0;
253
+ while (i <= line.length) {
254
+ if (i === line.length) break;
255
+ if (line[i] === '"') {
256
+ let field = "";
257
+ i++;
258
+ while (i < line.length) {
259
+ if (line[i] === '"' && i + 1 < line.length && line[i + 1] === '"') {
260
+ field += '"';
261
+ i += 2;
262
+ } else if (line[i] === '"') {
263
+ i++;
264
+ break;
265
+ } else {
266
+ field += line[i++];
267
+ }
268
+ }
269
+ fields.push(field);
270
+ if (i < line.length && line[i] === ",") i++;
271
+ } else {
272
+ const start = i;
273
+ while (i < line.length && line[i] !== ",") i++;
274
+ fields.push(line.slice(start, i));
275
+ if (i < line.length) i++;
276
+ }
277
+ }
278
+ return fields;
279
+ }
280
+ function parseCSV(input) {
281
+ const normalized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
282
+ return normalized.split("\n").map(parseCSVRow);
283
+ }
284
+ function parseDate(ddmmyyyy) {
285
+ const [dd, mm, yyyy] = ddmmyyyy.split("-");
286
+ return `${yyyy}-${mm}-${dd}`;
287
+ }
288
+ var DEGIROParser = class {
289
+ _warningEntries = [];
290
+ _snapshot;
291
+ constructor(snapshot) {
292
+ this._snapshot = snapshot;
293
+ }
294
+ parse(csv) {
295
+ this._warningEntries = [];
296
+ if (typeof csv !== "string" || csv.includes("\0")) {
297
+ throw new ParseError("INVALID_CSV");
298
+ }
299
+ let rows;
300
+ try {
301
+ rows = parseCSV(csv);
302
+ } catch {
303
+ throw new ParseError("INVALID_CSV");
304
+ }
305
+ if (rows.length === 0) {
306
+ throw new ParseError("INVALID_CSV");
307
+ }
308
+ const header = rows[0];
309
+ const colIndex = {};
310
+ for (let i = 0; i < header.length; i++) {
311
+ colIndex[header[i].trim()] = i;
312
+ }
313
+ for (const col of REQUIRED_COLUMNS) {
314
+ if (colIndex[col] === void 0) {
315
+ throw new ParseError("MISSING_COLUMN", col);
316
+ }
317
+ }
318
+ const snapshot = this._snapshot ?? getActiveSnapshot();
319
+ const transactions = [];
320
+ for (let r = 1; r < rows.length; r++) {
321
+ const row = rows[r];
322
+ const rowIndex = r + 1;
323
+ if (row.every((cell) => cell.trim() === "")) continue;
324
+ const get2 = (col) => (row[colIndex[col]] ?? "").trim();
325
+ const isin = get2("ISIN");
326
+ if (!isin) {
327
+ this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
328
+ continue;
329
+ }
330
+ const rawQty = parseFloat(get2("Quantity"));
331
+ if (isNaN(rawQty) || rawQty === 0) {
332
+ this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
333
+ continue;
334
+ }
335
+ const type = rawQty > 0 ? "BUY" : "SELL";
336
+ const quantity = Math.abs(rawQty);
337
+ const isoDate = parseDate(get2("Date"));
338
+ const currency = get2("Local value currency");
339
+ const rawTotalLocal = parseFloat(get2("Local value"));
340
+ const totalLocal = isNaN(rawTotalLocal) ? 0 : rawTotalLocal;
341
+ let totalEUR;
342
+ let fxRate;
343
+ if (currency === "EUR") {
344
+ totalEUR = Math.abs(totalLocal);
345
+ fxRate = void 0;
346
+ } else {
347
+ const rate = lookupRate(currency, isoDate, snapshot);
348
+ if (rate === null) {
349
+ if (snapshot[currency] === void 0) {
350
+ this._warningEntries.push({
351
+ code: "UNSUPPORTED_CURRENCY",
352
+ row: rowIndex,
353
+ currency
354
+ });
355
+ } else {
356
+ this._warningEntries.push({
357
+ code: "NO_ECB_RATE",
358
+ row: rowIndex,
359
+ currency,
360
+ date: isoDate
361
+ });
362
+ }
363
+ continue;
364
+ }
365
+ totalEUR = Math.abs(totalLocal) / rate;
366
+ fxRate = rate;
367
+ }
368
+ const rawFees = parseFloat(get2("Transaction costs"));
369
+ const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
370
+ const pricePerUnit = parseFloat(get2("Price"));
371
+ const product = get2("Product");
372
+ transactions.push({
373
+ isin,
374
+ product,
375
+ date: isoDate,
376
+ type,
377
+ quantity,
378
+ pricePerUnit,
379
+ currency,
380
+ totalLocal,
381
+ totalEUR,
382
+ feesEUR,
383
+ fxRate
384
+ });
385
+ }
386
+ return transactions;
387
+ }
388
+ get warnings() {
389
+ return this._warningEntries.map(warningToEnglish);
390
+ }
391
+ get warningEntries() {
392
+ return this._warningEntries;
393
+ }
394
+ };
395
+
396
+ // src/calculator/index.ts
397
+ function roundHalfUp(x) {
398
+ return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
399
+ }
400
+ function inferTaxYear(transactions) {
401
+ const counts = {};
402
+ for (const t of transactions) {
403
+ const y = t.date.slice(0, 4);
404
+ counts[y] = (counts[y] ?? 0) + 1;
405
+ }
406
+ const years = Object.keys(counts);
407
+ if (years.length === 0)
408
+ return { year: (/* @__PURE__ */ new Date()).getFullYear(), multipleYears: false };
409
+ const year = parseInt(
410
+ years.reduce((a, b) => counts[a] >= counts[b] ? a : b)
411
+ );
412
+ return { year, multipleYears: years.length > 1 };
413
+ }
414
+ var Calculator = class {
415
+ _transactions;
416
+ _parseWarnings;
417
+ constructor(transactions, parseWarnings) {
418
+ this._transactions = transactions;
419
+ this._parseWarnings = parseWarnings ?? [];
420
+ }
421
+ calculateGains(method) {
422
+ const warnings = [...this._parseWarnings];
423
+ const sorted = [...this._transactions].sort((a, b) => {
424
+ if (a.date < b.date) return -1;
425
+ if (a.date > b.date) return 1;
426
+ if (a.type === "BUY" && b.type === "SELL") return -1;
427
+ if (a.type === "SELL" && b.type === "BUY") return 1;
428
+ return 0;
429
+ });
430
+ const { year: taxYear, multipleYears } = inferTaxYear(sorted);
431
+ if (multipleYears) {
432
+ warnings.push(
433
+ "CSV contains transactions from multiple years \u2014 filter to a single year for accurate reporting."
434
+ );
435
+ }
436
+ const openLots = /* @__PURE__ */ new Map();
437
+ const matchedLots = [];
438
+ const ratesUsed = {};
439
+ for (const tx of sorted) {
440
+ if (tx.fxRate !== void 0) {
441
+ ratesUsed[`${tx.currency}:${tx.date}`] = tx.fxRate;
442
+ }
443
+ if (tx.type === "BUY") {
444
+ const lot = {
445
+ date: tx.date,
446
+ quantity: tx.quantity,
447
+ pricePerUnitEUR: tx.totalEUR / tx.quantity,
448
+ feesEUR: tx.feesEUR,
449
+ originalQty: tx.quantity,
450
+ fxRate: tx.fxRate
451
+ };
452
+ if (!openLots.has(tx.isin)) openLots.set(tx.isin, []);
453
+ openLots.get(tx.isin).push(lot);
454
+ } else {
455
+ const lots = openLots.get(tx.isin);
456
+ if (!lots || lots.length === 0) {
457
+ throw new CalculationError(tx.isin, tx.date);
458
+ }
459
+ const sellPricePerUnitEUR = tx.totalEUR / tx.quantity;
460
+ let remainingSellQty = tx.quantity;
461
+ while (remainingSellQty > 0) {
462
+ if (!lots || lots.length === 0) {
463
+ throw new CalculationError(tx.isin, tx.date);
464
+ }
465
+ const lot = method === "LIFO" ? lots[lots.length - 1] : lots[0];
466
+ const matchedQty = Math.min(lot.quantity, remainingSellQty);
467
+ const allocatedBuyFeesEUR = lot.feesEUR * (matchedQty / lot.originalQty);
468
+ const allocatedSellFeesEUR = tx.feesEUR * (matchedQty / tx.quantity);
469
+ const buyCostEUR = lot.pricePerUnitEUR * matchedQty + allocatedBuyFeesEUR;
470
+ const sellProceedsEUR = sellPricePerUnitEUR * matchedQty - allocatedSellFeesEUR;
471
+ const gainLossEUR = sellProceedsEUR - buyCostEUR;
472
+ matchedLots.push({
473
+ isin: tx.isin,
474
+ product: tx.product,
475
+ quantity: matchedQty,
476
+ buyDate: lot.date,
477
+ sellDate: tx.date,
478
+ buyPriceEUR: lot.pricePerUnitEUR,
479
+ sellPriceEUR: sellPricePerUnitEUR,
480
+ buyCostEUR: roundHalfUp(buyCostEUR),
481
+ sellProceedsEUR: roundHalfUp(sellProceedsEUR),
482
+ gainLossEUR: roundHalfUp(gainLossEUR),
483
+ buyFxRate: lot.fxRate,
484
+ sellFxRate: tx.fxRate
485
+ });
486
+ lot.quantity -= matchedQty;
487
+ remainingSellQty -= matchedQty;
488
+ if (lot.quantity <= 0) {
489
+ if (method === "LIFO") {
490
+ lots.pop();
491
+ } else {
492
+ lots.shift();
493
+ }
494
+ }
495
+ }
496
+ }
497
+ }
498
+ let plusvalenze = 0;
499
+ let minusvalenze = 0;
500
+ for (const lot of matchedLots) {
501
+ if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
502
+ else minusvalenze += Math.abs(lot.gainLossEUR);
503
+ }
504
+ return {
505
+ method,
506
+ taxYear,
507
+ plusvalenze: roundHalfUp(plusvalenze),
508
+ minusvalenze: roundHalfUp(minusvalenze),
509
+ netResult: roundHalfUp(plusvalenze - minusvalenze),
510
+ lots: matchedLots,
511
+ ratesUsed,
512
+ warnings,
513
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
514
+ };
515
+ }
516
+ };
517
+
518
+ // src/cli/renderer.ts
519
+ var SEPARATOR = "\u2500".repeat(72);
520
+ function formatEUR(amount, locale) {
521
+ return new Intl.NumberFormat(locale, {
522
+ minimumFractionDigits: 2,
523
+ maximumFractionDigits: 2
524
+ }).format(amount);
525
+ }
526
+ function formatGainLoss(amount, locale) {
527
+ const formatted = formatEUR(Math.abs(amount), locale);
528
+ return amount >= 0 ? `+${formatted}` : `-${formatted}`;
529
+ }
530
+ function renderReport(report, s) {
531
+ const fmt = (n) => formatEUR(n, s.numberLocale);
532
+ const lines = [];
533
+ lines.push(
534
+ `${s.headerMethod}: ${report.method} | ${s.headerTaxYear}: ${report.taxYear}`
535
+ );
536
+ lines.push("");
537
+ const headers = [
538
+ s.headerIsin.padEnd(14),
539
+ s.headerProduct.padEnd(20),
540
+ s.headerQty.padStart(5),
541
+ s.headerBuyDate.padEnd(12),
542
+ s.headerSellDate.padEnd(12),
543
+ s.headerBuyEur.padStart(14),
544
+ s.headerSellEur.padStart(13),
545
+ s.headerGainLoss.padStart(17)
546
+ ].join(" ");
547
+ lines.push(headers);
548
+ for (const lot of report.lots) {
549
+ const row = [
550
+ lot.isin.padEnd(14),
551
+ lot.product.substring(0, 20).padEnd(20),
552
+ String(lot.quantity).padStart(5),
553
+ lot.buyDate.padEnd(12),
554
+ lot.sellDate.padEnd(12),
555
+ fmt(lot.buyCostEUR).padStart(14),
556
+ fmt(lot.sellProceedsEUR).padStart(13),
557
+ formatGainLoss(lot.gainLossEUR, s.numberLocale).padStart(17)
558
+ ].join(" ");
559
+ lines.push(row);
560
+ }
561
+ lines.push("");
562
+ lines.push(SEPARATOR);
563
+ lines.push(`${s.summaryPlusvalenze}: ${fmt(report.plusvalenze)} EUR`);
564
+ lines.push(`${s.summaryMinusvalenze}: ${fmt(report.minusvalenze)} EUR`);
565
+ lines.push(`${s.summaryNetResult}: ${fmt(report.netResult)} EUR`);
566
+ lines.push("");
567
+ lines.push(`${s.summaryWarnings}: ${report.warnings.length}`);
568
+ lines.push(`${s.summaryGenerated}: ${report.generatedAt}`);
569
+ lines.push("");
570
+ lines.push(s.disclaimer);
571
+ return lines.join("\n");
572
+ }
573
+
574
+ // src/cli/commands/calc.ts
575
+ async function runCalc(positional, flags, s, stdout, stderr) {
576
+ const filePath = positional[0];
577
+ if (!filePath) {
578
+ stderr.write(
579
+ "Usage: minus-tracker calc [--method LIFO|FIFO] [--json] <file.csv>\n"
580
+ );
581
+ return 2;
582
+ }
583
+ let csv;
584
+ try {
585
+ csv = fs3.readFileSync(filePath, "utf8");
586
+ } catch {
587
+ stderr.write(`Cannot read file: ${filePath}
588
+ `);
589
+ return 1;
590
+ }
591
+ const method = flags["method"] ?? "LIFO";
592
+ if (method !== "LIFO" && method !== "FIFO") {
593
+ stderr.write("--method must be LIFO or FIFO\n");
594
+ return 2;
595
+ }
596
+ const parser = new DEGIROParser();
597
+ let transactions;
598
+ try {
599
+ transactions = parser.parse(csv);
600
+ } catch (err) {
601
+ if (err instanceof ParseError) {
602
+ if (err.code === "INVALID_CSV") {
603
+ stderr.write(s.errorInvalidCsv + "\n");
604
+ } else {
605
+ stderr.write(s.errorMissingColumn(err.columnName) + "\n");
606
+ }
607
+ return 1;
608
+ }
609
+ throw err;
610
+ }
611
+ const calculator = new Calculator(transactions, parser.warnings);
612
+ const report = calculator.calculateGains(method);
613
+ if (flags["json"]) {
614
+ stdout.write(JSON.stringify(report, null, 2) + "\n");
615
+ } else {
616
+ stdout.write(renderReport(report, s) + "\n");
617
+ }
618
+ return 0;
619
+ }
620
+
621
+ // src/cli/commands/validate.ts
622
+ import * as fs4 from "fs";
623
+ async function runValidate(positional, flags, s, stdout, stderr) {
624
+ const filePath = positional[0];
625
+ if (!filePath) {
626
+ stderr.write("Usage: minus-tracker validate <file.csv>\n");
627
+ return 2;
628
+ }
629
+ let csv;
630
+ try {
631
+ csv = fs4.readFileSync(filePath, "utf8");
632
+ } catch {
633
+ stderr.write(`Cannot read file: ${filePath}
634
+ `);
635
+ return 1;
636
+ }
637
+ const parser = new DEGIROParser();
638
+ let transactions;
639
+ try {
640
+ transactions = parser.parse(csv);
641
+ } catch (err) {
642
+ if (err instanceof ParseError) {
643
+ if (err.code === "INVALID_CSV") {
644
+ stderr.write(s.errorInvalidCsv + "\n");
645
+ } else {
646
+ stderr.write(s.errorMissingColumn(err.columnName) + "\n");
647
+ }
648
+ return 1;
649
+ }
650
+ throw err;
651
+ }
652
+ stdout.write(s.validateOk(transactions.length, 0) + "\n");
653
+ for (const entry of parser.warningEntries) {
654
+ let reason;
655
+ switch (entry.code) {
656
+ case "MISSING_ISIN":
657
+ reason = s.warnMissingIsin(entry.row);
658
+ break;
659
+ case "UNSUPPORTED_CURRENCY":
660
+ reason = s.warnUnsupportedCurrency(entry.row, entry.currency);
661
+ break;
662
+ case "NO_ECB_RATE":
663
+ reason = s.warnNoEcbRate(entry.row, entry.currency, entry.date);
664
+ break;
665
+ case "QUANTITY_ZERO":
666
+ reason = s.warnQuantityZero(entry.row);
667
+ break;
668
+ }
669
+ stdout.write(reason + "\n");
670
+ }
671
+ return 0;
672
+ }
673
+
674
+ // src/cli/commands/rates.ts
675
+ import * as fs5 from "fs";
676
+ import * as path3 from "path";
677
+ import * as os2 from "os";
678
+ import * as https from "https";
679
+ function getSnapshotPath() {
680
+ const platform = process.platform;
681
+ let configDir;
682
+ if (platform === "win32") {
683
+ configDir = process.env.APPDATA ?? path3.join(os2.homedir(), "AppData", "Roaming");
684
+ } else {
685
+ configDir = process.env.XDG_CONFIG_HOME ?? path3.join(os2.homedir(), ".config");
686
+ }
687
+ return path3.join(configDir, "minus-tracker", "ecb-rates.json");
688
+ }
689
+ function getCoverage(snapshot) {
690
+ let start = "9999-12-31";
691
+ let end = "0000-01-01";
692
+ const currencies = [];
693
+ for (const [ccy, dates] of Object.entries(snapshot)) {
694
+ currencies.push(ccy);
695
+ for (const d of Object.keys(dates)) {
696
+ if (d < start) start = d;
697
+ if (d > end) end = d;
698
+ }
699
+ }
700
+ return { start, end, currencies: currencies.sort().join(", ") };
701
+ }
702
+ async function fetchEcbData(currency) {
703
+ return new Promise((resolve, reject) => {
704
+ const url = `https://data-api.ecb.europa.eu/service/data/EXR/D.${currency}.EUR.SP00.A?format=csvdata&startPeriod=2019-01-01`;
705
+ https.get(url, (res) => {
706
+ let data = "";
707
+ res.on("data", (chunk) => data += chunk);
708
+ res.on("end", () => {
709
+ const rates = {};
710
+ const lines = data.split("\n");
711
+ for (const line of lines) {
712
+ const dateMatch = line.match(
713
+ /^(\d{4}-\d{2}-\d{2}),.*?,.*?,.*?,.*?,([\d.]+)/
714
+ );
715
+ if (dateMatch) {
716
+ rates[dateMatch[1]] = parseFloat(dateMatch[2]);
717
+ }
718
+ }
719
+ resolve(rates);
720
+ });
721
+ res.on("error", reject);
722
+ }).on("error", reject);
723
+ });
724
+ }
725
+ async function runRates(positional, flags, s, stdout, stderr) {
726
+ if (flags["check"]) {
727
+ const snapshot = getActiveSnapshot();
728
+ const { start, end, currencies } = getCoverage(snapshot);
729
+ stdout.write(s.ratesCoverage(start, end, currencies) + "\n");
730
+ stdout.write(s.ratesGapsNone + "\n");
731
+ return 0;
732
+ }
733
+ if (flags["update"]) {
734
+ stdout.write(s.ratesUpdateFetching + "\n");
735
+ const snapshotPath = getSnapshotPath();
736
+ let existing = {};
737
+ try {
738
+ existing = JSON.parse(
739
+ fs5.readFileSync(snapshotPath, "utf8")
740
+ );
741
+ } catch {
742
+ }
743
+ let addedCount = 0;
744
+ for (const currency of ["USD", "GBP", "CHF"]) {
745
+ try {
746
+ const newRates = await fetchEcbData(currency);
747
+ const existing_ccy = existing[currency] ?? {};
748
+ let added = 0;
749
+ for (const [date, rate] of Object.entries(newRates)) {
750
+ if (!existing_ccy[date]) added++;
751
+ existing_ccy[date] = rate;
752
+ }
753
+ existing[currency] = existing_ccy;
754
+ addedCount += added;
755
+ } catch {
756
+ stderr.write(`Failed to fetch ${currency} rates
757
+ `);
758
+ }
759
+ }
760
+ fs5.mkdirSync(path3.dirname(snapshotPath), { recursive: true });
761
+ fs5.writeFileSync(
762
+ snapshotPath,
763
+ JSON.stringify(existing, null, 2) + "\n",
764
+ "utf8"
765
+ );
766
+ stdout.write(s.ratesUpdateDone(addedCount) + "\n");
767
+ stdout.write(s.ratesSnapshotWritten(snapshotPath) + "\n");
768
+ return 0;
769
+ }
770
+ stderr.write("Usage: minus-tracker rates --check | --update\n");
771
+ return 2;
772
+ }
773
+
774
+ // src/cli/commands/config.ts
775
+ var SUPPORTED_LOCALES2 = ["it", "en"];
776
+ async function runConfig(positional, flags, s, stdout, stderr) {
777
+ if (flags["lang"] !== void 0) {
778
+ const lang = flags["lang"];
779
+ if (!SUPPORTED_LOCALES2.includes(lang)) {
780
+ stderr.write(`Unsupported locale "${lang}". Use: it, en
781
+ `);
782
+ return 2;
783
+ }
784
+ saveLocale(lang);
785
+ stdout.write(s.configLangSet(lang) + "\n");
786
+ return 0;
787
+ }
788
+ if (flags["show"]) {
789
+ const locale = resolveLocale();
790
+ stdout.write(s.configCurrentLang(locale) + "\n");
791
+ return 0;
792
+ }
793
+ stderr.write("Usage: minus-tracker config --lang <it|en> | --show\n");
794
+ return 2;
795
+ }
796
+
797
+ // src/cli/index.ts
798
+ async function main() {
799
+ const { values, positionals } = parseArgs({
800
+ args: process.argv.slice(2),
801
+ options: {
802
+ lang: { type: "string" },
803
+ method: { type: "string" },
804
+ year: { type: "string" },
805
+ json: { type: "boolean", default: false },
806
+ check: { type: "boolean", default: false },
807
+ update: { type: "boolean", default: false },
808
+ show: { type: "boolean", default: false }
809
+ },
810
+ allowPositionals: true,
811
+ strict: false
812
+ });
813
+ const locale = resolveLocale(values.lang);
814
+ const s = getStrings(locale);
815
+ const command = positionals[0];
816
+ const restPositionals = positionals.slice(1);
817
+ const flags = values;
818
+ const stdout = process.stdout;
819
+ const stderr = process.stderr;
820
+ let exitCode = 0;
821
+ try {
822
+ switch (command) {
823
+ case "calc":
824
+ exitCode = await runCalc(restPositionals, flags, s, stdout, stderr);
825
+ break;
826
+ case "validate":
827
+ exitCode = await runValidate(restPositionals, flags, s, stdout, stderr);
828
+ break;
829
+ case "rates":
830
+ exitCode = await runRates(restPositionals, flags, s, stdout, stderr);
831
+ break;
832
+ case "config":
833
+ exitCode = await runConfig(restPositionals, flags, s, stdout, stderr);
834
+ break;
835
+ default:
836
+ stderr.write(
837
+ "Usage: minus-tracker <calc|validate|rates|config> [options] [file]\n"
838
+ );
839
+ exitCode = 2;
840
+ }
841
+ } catch (err) {
842
+ if (err instanceof ParseError) {
843
+ if (err.code === "INVALID_CSV") {
844
+ stderr.write(s.errorInvalidCsv + "\n");
845
+ } else {
846
+ stderr.write(s.errorMissingColumn(err.columnName) + "\n");
847
+ }
848
+ exitCode = 1;
849
+ } else if (err instanceof CalculationError) {
850
+ stderr.write(s.errorNoOpenLots(err.isin, err.date) + "\n");
851
+ exitCode = 1;
852
+ } else {
853
+ throw err;
854
+ }
855
+ }
856
+ process.exit(exitCode);
857
+ }
858
+ main().catch((err) => {
859
+ process.stderr.write(`Unhandled error: ${String(err)}
860
+ `);
861
+ process.exit(1);
862
+ });
863
+ //# sourceMappingURL=index.js.map