@gabrielerandelli/minus-tracker 0.5.8 → 0.7.0

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/cli/index.js CHANGED
@@ -13,6 +13,8 @@ var it = {
13
13
  warnUnsupportedCurrency: (row, currency) => `Riga ${row}: valuta non supportata ${currency} \u2014 riga ignorata`,
14
14
  warnNoEcbRate: (row, currency, date) => `Riga ${row}: nessun tasso BCE per ${currency} in data ${date} \u2014 riga ignorata`,
15
15
  warnQuantityZero: (row) => `Riga ${row}: quantit\xE0 pari a 0 \u2014 riga ignorata`,
16
+ warnMissingIsinIncome: (row) => `Riga ${row}: ISIN mancante su riga di reddito \u2014 riga ignorata`,
17
+ warnOrphanWithholding: (isin, date) => `Riga di ritenuta su ${isin}/${date} senza riga di reddito corrispondente \u2014 riga ignorata`,
16
18
  warnMultipleYears: "Il CSV contiene transazioni di pi\xF9 anni \u2014 filtra per un singolo anno per un calcolo accurato.",
17
19
  headerMethod: "METODO",
18
20
  headerTaxYear: "ANNO FISCALE",
@@ -36,12 +38,58 @@ var it = {
36
38
  ratesGaps: (list) => `Lacune: ${list}`,
37
39
  ratesUpdateFetching: "Recupero dati BCE SDMX in corso...",
38
40
  ratesUpdateDone: (n) => `Completato. Aggiunte ${n} nuove date.`,
39
- ratesSnapshotWritten: (path5) => `Snapshot scritto in ${path5}`,
41
+ ratesSnapshotWritten: (path7) => `Snapshot scritto in ${path7}`,
40
42
  ratesAutoUpdateStart: "Le rate BCE non sono aggiornate. Aggiornamento automatico in corso...",
41
43
  ratesAutoUpdateFailed: "Aggiornamento automatico fallito. Si procede con le rate disponibili.",
42
44
  configLangSet: (lang) => `Lingua impostata su: ${lang}`,
43
45
  configCurrentLang: (lang) => `Lingua corrente: ${lang}`,
44
- disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale."
46
+ classifyStarting: (n) => `Classificazione ISIN in corso... (${n} strumenti)`,
47
+ classifyDetected: (isin, product, bucket, rate) => `Rilevato: ${product} (${isin}) \u2192 ${bucket} | ${rate}`,
48
+ classifyConfirm: "[Y/n/?]: ",
49
+ classifyOverridePrompt: "Tipo di strumento:",
50
+ classifyDone: (confirmed, total) => `Classificazione completata (${confirmed}/${total} confermati).`,
51
+ classifyWritten: (path7) => `Scritto: ${path7}`,
52
+ classifyMergePrompt: (existing) => `File gi\xE0 esistente (${existing} ISIN confermati). Aggiungere i nuovi ISIN? [Y/n]: `,
53
+ classifyOfflineWarning: "Modalit\xE0 offline: ricerca OpenFIGI saltata.",
54
+ classifyUnknownType: (type) => `Tipo non riconosciuto: ${type}. Classificare manualmente.`,
55
+ classifyNonTtyError: "Modalit\xE0 interattiva richiede un terminale (TTY). Usa --offline per classificare senza terminale.",
56
+ bucketAHeader: "BUCKET A \u2014 REDDITI DA CAPITALE (non compensabili)",
57
+ bucketBHeader: "BUCKET B \u2014 REDDITI DIVERSI",
58
+ bucketAEtf: "ETF (OICR)",
59
+ bucketABtpWl: "BTP/WL",
60
+ bucketATotalTax: "TOTALE IMPOSTA",
61
+ bucketBCarryApplied: (year) => `RIPORTO ${year}`,
62
+ bucketBResult: "RISULTATO",
63
+ bucketBCarryNote: "(riportabile ai prossimi 4 anni)",
64
+ warnMixedBuckets: "Le perdite in Bucket B non compensano le plusvalenze in Bucket A.",
65
+ warnMixedAssets: "AVVISO: CSV contiene tipi di strumenti misti (es. ETF + Azioni).\n Il calcolo a bucket unico pu\xF2 non essere fiscalmente corretto.\n Esegui: minus-tracker classify trades.csv",
66
+ headerBucket: "BUCKET",
67
+ warnUnclassifiedIsin: (isin) => `ISIN ${isin} non trovato nella mappa di classificazione \u2014 assegnato a Bucket B.`,
68
+ carryForwardInvalidFormat: "Formato --carry-forward non valido. Usa: AAAA:importo (es. 2023:2500)",
69
+ disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale.",
70
+ // v0.7.0 dichiarazione section
71
+ dichiarazioneHeader: (anno) => `\u2500\u2500 MODELLO REDDITI PF [ANNO D'IMPOSTA: ${anno}]`,
72
+ dichiarazioneNota: "Nota: i valori riportano i codici riga indicativi \u2014 verificare la modulistica ufficiale.",
73
+ dichiarazioneWarningRow: "\u26A0 I codici riga tra parentesi quadre sono indicativi. Verificare la versione corrente del modello.",
74
+ quadroRTHeader: "QUADRO RT \u2014 SEZIONE II (redditi diversi)",
75
+ quadroRTPlusvalenze: "[RT-P] Plusvalenze totali",
76
+ quadroRTMinusvalenze: "[RT-M] Minusvalenze totali",
77
+ quadroRTDifferenza: "[RT-D] Differenza",
78
+ quadroRTRiporto: (anno) => `[RT-C*] Riporto ${anno} (consumato)`,
79
+ quadroRTImponibile: "[RT-N] Imponibile netto",
80
+ quadroRTImposta: "[RT-I] Imposta (26%)",
81
+ quadroRTRiportabile: "[RT-R] Minusvalenze da riportare",
82
+ quadroRTPerdita: "perdita netta:",
83
+ quadroRMHeader: "QUADRO RM (redditi da capitale)",
84
+ quadroRMEtf26: "[RM-A1] ETF/OICR plusvalenze (26%)",
85
+ quadroRMImposta: "Imposta sostitutiva",
86
+ quadroRMBtp: "[RM-A2] BTP/WL plusvalenze (12,5%)",
87
+ quadroRMDividendi: "[RM-D] Dividendi esteri lordi",
88
+ quadroRMRitenuta: "Ritenuta estera (credito)",
89
+ quadroRMCedole: "[RM-C] Cedole obbligazionarie",
90
+ dichiarazioneDisclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale.",
91
+ warnNoDichiarazioneSidecar: "Eseguire prima: minus-tracker classify <file>",
92
+ warnNoCarryForwardProvided: "(eventuali perdite pregresse non applicate)"
45
93
  };
46
94
 
47
95
  // src/i18n/en.ts
@@ -54,6 +102,8 @@ var en = {
54
102
  warnUnsupportedCurrency: (row, currency) => `Row ${row}: unsupported currency ${currency} \u2014 skipped`,
55
103
  warnNoEcbRate: (row, currency, date) => `Row ${row}: no ECB rate for ${currency} on ${date} \u2014 skipped`,
56
104
  warnQuantityZero: (row) => `Row ${row}: quantity is 0 \u2014 skipped`,
105
+ warnMissingIsinIncome: (row) => `Row ${row}: income row has blank ISIN \u2014 skipped`,
106
+ warnOrphanWithholding: (isin, date) => `Withholding row on ${isin}/${date} has no matching income row \u2014 skipped`,
57
107
  warnMultipleYears: "CSV contains transactions from multiple years \u2014 filter to a single year for accurate reporting.",
58
108
  headerMethod: "METHOD",
59
109
  headerTaxYear: "TAX YEAR",
@@ -77,12 +127,58 @@ var en = {
77
127
  ratesGaps: (list) => `Gaps: ${list}`,
78
128
  ratesUpdateFetching: "Fetching ECB SDMX...",
79
129
  ratesUpdateDone: (n) => `Done. Added ${n} new dates.`,
80
- ratesSnapshotWritten: (path5) => `Snapshot written to ${path5}`,
130
+ ratesSnapshotWritten: (path7) => `Snapshot written to ${path7}`,
81
131
  ratesAutoUpdateStart: "ECB rates are stale. Auto-updating\u2026",
82
132
  ratesAutoUpdateFailed: "Auto-update failed. Proceeding with available rates.",
83
133
  configLangSet: (lang) => `Language set to: ${lang}`,
84
134
  configCurrentLang: (lang) => `Current language: ${lang}`,
85
- disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale."
135
+ classifyStarting: (n) => `Classifying ISINs... (${n} instruments)`,
136
+ classifyDetected: (isin, product, bucket, rate) => `Detected: ${product} (${isin}) \u2192 ${bucket} | ${rate}`,
137
+ classifyConfirm: "[Y/n/?]: ",
138
+ classifyOverridePrompt: "Instrument type:",
139
+ classifyDone: (confirmed, total) => `Classification complete (${confirmed}/${total} confirmed).`,
140
+ classifyWritten: (path7) => `Written: ${path7}`,
141
+ classifyMergePrompt: (existing) => `File already exists (${existing} confirmed ISINs). Add new ISINs? [Y/n]: `,
142
+ classifyOfflineWarning: "Offline mode: OpenFIGI lookup skipped.",
143
+ classifyUnknownType: (type) => `Unrecognized type: ${type}. Please classify manually.`,
144
+ classifyNonTtyError: "Interactive mode requires a TTY. Use --offline to classify without a terminal.",
145
+ bucketAHeader: "BUCKET A \u2014 CAPITAL INCOME (non-offsettable)",
146
+ bucketBHeader: "BUCKET B \u2014 MISCELLANEOUS INCOME",
147
+ bucketAEtf: "ETF (OICR)",
148
+ bucketABtpWl: "Govt Bond WL",
149
+ bucketATotalTax: "TOTAL TAX",
150
+ bucketBCarryApplied: (year) => `CARRY ${year}`,
151
+ bucketBResult: "RESULT",
152
+ bucketBCarryNote: "(carriable over the next 4 years)",
153
+ warnMixedBuckets: "Bucket B losses do not offset Bucket A plusvalenze.",
154
+ warnMixedAssets: "WARNING: CSV contains mixed instrument types (e.g. ETFs + Stocks).\n Single-bucket calculation may not be fiscally correct.\n Run: minus-tracker classify trades.csv",
155
+ headerBucket: "BUCKET",
156
+ warnUnclassifiedIsin: (isin) => `ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`,
157
+ carryForwardInvalidFormat: "Invalid --carry-forward format. Use: YYYY:amount (e.g. 2023:2500)",
158
+ disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale.",
159
+ // v0.7.0 dichiarazione section
160
+ dichiarazioneHeader: (anno) => `\u2500\u2500 MODELLO REDDITI PF [TAX YEAR: ${anno}]`,
161
+ dichiarazioneNota: "Note: row codes are indicative \u2014 verify against the official tax form.",
162
+ dichiarazioneWarningRow: "\u26A0 Row codes in square brackets are indicative. Check the current form version.",
163
+ quadroRTHeader: "QUADRO RT \u2014 SECTION II (capital gains)",
164
+ quadroRTPlusvalenze: "[RT-P] Total gains",
165
+ quadroRTMinusvalenze: "[RT-M] Total losses",
166
+ quadroRTDifferenza: "[RT-D] Difference",
167
+ quadroRTRiporto: (anno) => `[RT-C*] Prior loss ${anno} (applied)`,
168
+ quadroRTImponibile: "[RT-N] Net taxable",
169
+ quadroRTImposta: "[RT-I] Tax (26%)",
170
+ quadroRTRiportabile: "[RT-R] Losses to carry forward",
171
+ quadroRTPerdita: "net loss:",
172
+ quadroRMHeader: "QUADRO RM (capital income)",
173
+ quadroRMEtf26: "[RM-A1] ETF/OICR gains (26%)",
174
+ quadroRMImposta: "Substitute tax",
175
+ quadroRMBtp: "[RM-A2] BTP/WL gains (12.5%)",
176
+ quadroRMDividendi: "[RM-D] Foreign dividends (gross)",
177
+ quadroRMRitenuta: "Foreign withholding (credit)",
178
+ quadroRMCedole: "[RM-C] Bond coupons",
179
+ dichiarazioneDisclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale.",
180
+ warnNoDichiarazioneSidecar: "Run first: minus-tracker classify <file>",
181
+ warnNoCarryForwardProvided: "(prior-year losses not applied)"
86
182
  };
87
183
 
88
184
  // src/i18n/settings.ts
@@ -165,9 +261,21 @@ var CalculationError = class extends Error {
165
261
  this.date = date;
166
262
  }
167
263
  };
264
+ var ClassificationError = class extends Error {
265
+ code;
266
+ constructor(code, message) {
267
+ super(
268
+ message ?? (code === "SIDECAR_NOT_FOUND" ? "Sidecar file not found" : code === "SIDECAR_VERSION" ? "Sidecar version mismatch" : code === "SIDECAR_MALFORMED" ? "Sidecar file is malformed JSON" : code === "NETWORK_ERROR" ? "Network error contacting OpenFIGI" : "Failed to write sidecar file")
269
+ );
270
+ this.name = "ClassificationError";
271
+ this.code = code;
272
+ }
273
+ };
168
274
 
169
275
  // src/cli/commands/calc.ts
170
276
  import * as fs4 from "fs";
277
+ import * as path3 from "path";
278
+ import * as os2 from "os";
171
279
 
172
280
  // src/rates/index.ts
173
281
  import { fileURLToPath } from "url";
@@ -218,18 +326,6 @@ function getActiveSnapshot() {
218
326
  }
219
327
  return merged;
220
328
  }
221
- function isSnapshotStale(snapshot, today) {
222
- const todayStr = today ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
223
- let maxDate = "0000-01-01";
224
- for (const dates of Object.values(snapshot)) {
225
- for (const d of Object.keys(dates)) {
226
- if (d > maxDate) maxDate = d;
227
- }
228
- }
229
- if (maxDate === "0000-01-01") return true;
230
- const diffDays = (new Date(todayStr).getTime() - new Date(maxDate).getTime()) / (1e3 * 60 * 60 * 24);
231
- return diffDays >= 7;
232
- }
233
329
  function subtractDays(isoDate, days) {
234
330
  const d = /* @__PURE__ */ new Date(isoDate + "T00:00:00Z");
235
331
  d.setUTCDate(d.getUTCDate() - days);
@@ -260,6 +356,10 @@ function warningToEnglish(w) {
260
356
  return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
261
357
  case "QUANTITY_ZERO":
262
358
  return `Row ${w.row}: quantity is 0 \u2014 skipped`;
359
+ case "MISSING_ISIN_INCOME":
360
+ return `Row ${w.row}: blank ISIN on income row \u2014 skipped`;
361
+ case "ORPHAN_WITHHOLDING":
362
+ return `Withholding row for ${w.isin} on ${w.date}: no matching income row \u2014 skipped`;
263
363
  }
264
364
  }
265
365
 
@@ -275,6 +375,8 @@ var REQUIRED_COLUMNS = [
275
375
  "Transaction costs currency",
276
376
  "Product"
277
377
  ];
378
+ var INCOME_KEYWORDS = ["DIVIDEND", "COUPON", "INTEREST", "CEDOLA"];
379
+ var TAX_KEYWORDS = ["DIVIDEND TAX", "WITHHOLDING", "RITENUTA"];
278
380
  function parseCSVRow(line) {
279
381
  const fields = [];
280
382
  let i = 0;
@@ -316,6 +418,7 @@ function parseDate(ddmmyyyy) {
316
418
  var DEGIROParser = class {
317
419
  _warningEntries = [];
318
420
  _snapshot;
421
+ _incomeRows = [];
319
422
  constructor(snapshot) {
320
423
  this._snapshot = snapshot;
321
424
  }
@@ -332,6 +435,7 @@ var DEGIROParser = class {
332
435
  */
333
436
  parse(csv) {
334
437
  this._warningEntries = [];
438
+ this._incomeRows = [];
335
439
  if (typeof csv !== "string" || csv.includes("\0")) {
336
440
  throw new ParseError("INVALID_CSV");
337
441
  }
@@ -356,17 +460,70 @@ var DEGIROParser = class {
356
460
  }
357
461
  const snapshot = this._snapshot ?? getActiveSnapshot();
358
462
  const transactions = [];
463
+ const incomeCandidates = [];
464
+ const withholdingCandidates = [];
359
465
  for (let r = 1; r < rows.length; r++) {
360
466
  const row = rows[r];
361
467
  const rowIndex = r + 1;
362
468
  if (row.every((cell) => cell.trim() === "")) continue;
363
469
  const get2 = (col) => (row[colIndex[col]] ?? "").trim();
364
470
  const isin = get2("ISIN");
471
+ const product = get2("Product");
472
+ const qtyStr = get2("Quantity");
473
+ const rawQty = parseFloat(qtyStr);
474
+ const qtyIsZeroOrBlank = qtyStr === "" || isNaN(rawQty) || rawQty === 0;
475
+ if (qtyIsZeroOrBlank) {
476
+ const upperProduct = product.toUpperCase();
477
+ const isTaxKeyword = TAX_KEYWORDS.some((k) => upperProduct.includes(k));
478
+ const isIncomeKeyword = !isTaxKeyword && INCOME_KEYWORDS.some((k) => upperProduct.includes(k));
479
+ if (isTaxKeyword || isIncomeKeyword) {
480
+ const rawLocalValue = parseFloat(get2("Local value"));
481
+ const localValue = isNaN(rawLocalValue) ? 0 : rawLocalValue;
482
+ const isoDate2 = parseDate(get2("Date"));
483
+ if (isTaxKeyword && localValue < 0) {
484
+ if (!isin) {
485
+ this._warningEntries.push({
486
+ code: "MISSING_ISIN_INCOME",
487
+ row: rowIndex
488
+ });
489
+ continue;
490
+ }
491
+ withholdingCandidates.push({
492
+ isin,
493
+ date: isoDate2,
494
+ amount: Math.abs(localValue),
495
+ row: rowIndex
496
+ });
497
+ continue;
498
+ }
499
+ if (isIncomeKeyword && localValue > 0) {
500
+ if (!isin) {
501
+ this._warningEntries.push({
502
+ code: "MISSING_ISIN_INCOME",
503
+ row: rowIndex
504
+ });
505
+ continue;
506
+ }
507
+ const incomeType = upperProduct.includes(
508
+ "DIVIDEND"
509
+ ) ? "dividend" : "coupon";
510
+ incomeCandidates.push({
511
+ isin,
512
+ product,
513
+ date: isoDate2,
514
+ incomeType,
515
+ localValue,
516
+ currency: get2("Local value currency"),
517
+ row: rowIndex
518
+ });
519
+ continue;
520
+ }
521
+ }
522
+ }
365
523
  if (!isin) {
366
524
  this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
367
525
  continue;
368
526
  }
369
- const rawQty = parseFloat(get2("Quantity"));
370
527
  if (isNaN(rawQty) || rawQty === 0) {
371
528
  this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
372
529
  continue;
@@ -407,7 +564,6 @@ var DEGIROParser = class {
407
564
  const rawFees = parseFloat(get2("Transaction costs"));
408
565
  const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
409
566
  const pricePerUnit = parseFloat(get2("Price"));
410
- const product = get2("Product");
411
567
  transactions.push({
412
568
  isin,
413
569
  product,
@@ -422,6 +578,73 @@ var DEGIROParser = class {
422
578
  fxRate
423
579
  });
424
580
  }
581
+ const withholdingByKey = /* @__PURE__ */ new Map();
582
+ for (const w of withholdingCandidates) {
583
+ const key = `${w.isin}|${w.date}`;
584
+ const existing = withholdingByKey.get(key);
585
+ withholdingByKey.set(key, {
586
+ isin: w.isin,
587
+ date: w.date,
588
+ amount: (existing?.amount ?? 0) + w.amount
589
+ });
590
+ }
591
+ const consumedKeys = /* @__PURE__ */ new Set();
592
+ const incomeRows = [];
593
+ for (const c of incomeCandidates) {
594
+ const key = `${c.isin}|${c.date}`;
595
+ const matchedWithholding = withholdingByKey.get(key);
596
+ let grossAmount;
597
+ let withholdingTax;
598
+ let fxRate;
599
+ if (c.currency === "EUR") {
600
+ grossAmount = Math.abs(c.localValue);
601
+ withholdingTax = matchedWithholding?.amount ?? 0;
602
+ fxRate = void 0;
603
+ } else {
604
+ const rate = lookupRate(c.currency, c.date, snapshot);
605
+ if (rate === null) {
606
+ if (snapshot[c.currency] === void 0) {
607
+ this._warningEntries.push({
608
+ code: "UNSUPPORTED_CURRENCY",
609
+ row: c.row,
610
+ currency: c.currency
611
+ });
612
+ } else {
613
+ this._warningEntries.push({
614
+ code: "NO_ECB_RATE",
615
+ row: c.row,
616
+ currency: c.currency,
617
+ date: c.date
618
+ });
619
+ }
620
+ continue;
621
+ }
622
+ grossAmount = Math.abs(c.localValue) / rate;
623
+ withholdingTax = matchedWithholding ? matchedWithholding.amount / rate : 0;
624
+ fxRate = rate;
625
+ }
626
+ if (matchedWithholding) consumedKeys.add(key);
627
+ incomeRows.push({
628
+ isin: c.isin,
629
+ product: c.product,
630
+ date: c.date,
631
+ incomeType: c.incomeType,
632
+ grossAmount,
633
+ withholdingTax,
634
+ currency: c.currency,
635
+ fxRate
636
+ });
637
+ }
638
+ for (const [key, w] of withholdingByKey) {
639
+ if (!consumedKeys.has(key)) {
640
+ this._warningEntries.push({
641
+ code: "ORPHAN_WITHHOLDING",
642
+ isin: w.isin,
643
+ date: w.date
644
+ });
645
+ }
646
+ }
647
+ this._incomeRows = incomeRows;
425
648
  return transactions;
426
649
  }
427
650
  get warnings() {
@@ -430,12 +653,136 @@ var DEGIROParser = class {
430
653
  get warningEntries() {
431
654
  return this._warningEntries;
432
655
  }
656
+ get incomeRows() {
657
+ return this._incomeRows;
658
+ }
433
659
  };
434
660
 
435
- // src/calculator/index.ts
661
+ // src/dichiarazione/engine.ts
662
+ import { writeFile } from "fs/promises";
436
663
  function roundHalfUp(x) {
437
664
  return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
438
665
  }
666
+ function buildQuadroRT(bucketB, carryForward, taxYear) {
667
+ const plusvalenze = bucketB.plusvalenze;
668
+ const minusvalenze = bucketB.minusvalenze;
669
+ const differenza = roundHalfUp(plusvalenze - minusvalenze);
670
+ if (differenza > 0) {
671
+ const sorted = [...carryForward].sort((a, b) => a.year - b.year);
672
+ let remaining = differenza;
673
+ const carryForwardApplied = [];
674
+ for (const entry of sorted) {
675
+ if (taxYear - entry.year > 4) continue;
676
+ const consumed = Math.min(entry.amount, remaining);
677
+ if (consumed > 0) {
678
+ carryForwardApplied.push({
679
+ annoOrigine: entry.year,
680
+ importo: roundHalfUp(consumed)
681
+ });
682
+ remaining -= consumed;
683
+ }
684
+ }
685
+ const imponibileNetto = roundHalfUp(remaining);
686
+ const imposta = roundHalfUp(imponibileNetto * 0.26);
687
+ return {
688
+ plusvalenze,
689
+ minusvalenze,
690
+ differenza,
691
+ carryForwardApplied,
692
+ imponibileNetto,
693
+ imposta,
694
+ carryForwardRiportato: []
695
+ };
696
+ } else if (differenza < 0) {
697
+ return {
698
+ plusvalenze,
699
+ minusvalenze,
700
+ differenza,
701
+ carryForwardApplied: [],
702
+ imponibileNetto: 0,
703
+ imposta: 0,
704
+ carryForwardRiportato: [
705
+ { annoOrigine: taxYear, importo: roundHalfUp(Math.abs(differenza)) }
706
+ ]
707
+ };
708
+ } else {
709
+ return {
710
+ plusvalenze,
711
+ minusvalenze,
712
+ differenza: 0,
713
+ carryForwardApplied: [],
714
+ imponibileNetto: 0,
715
+ imposta: 0,
716
+ carryForwardRiportato: []
717
+ };
718
+ }
719
+ }
720
+ function buildQuadroRM(bucketA, incomeRows, taxYear) {
721
+ const groups = bucketA?.groups ?? [];
722
+ const group26 = groups.find((g) => g.taxRate === 0.26);
723
+ const group125 = groups.find((g) => g.taxRate === 0.125);
724
+ const capitaleAliquota26 = {
725
+ plusvalenze: group26?.plusvalenze ?? 0,
726
+ imposta: group26?.imposta ?? 0
727
+ };
728
+ const capitaleAliquota125 = {
729
+ plusvalenze: group125?.plusvalenze ?? 0,
730
+ imposta: group125?.imposta ?? 0
731
+ };
732
+ const dividendiEsteri = incomeRows.filter((r) => r.incomeType === "dividend").map((r) => ({
733
+ isin: r.isin,
734
+ prodotto: r.product,
735
+ lordo: r.grossAmount,
736
+ rittenutaEstera: r.withholdingTax
737
+ }));
738
+ const cedole = incomeRows.filter((r) => r.incomeType === "coupon").map((r) => ({
739
+ isin: r.isin,
740
+ prodotto: r.product,
741
+ importo: r.grossAmount,
742
+ rittenutaEstera: r.withholdingTax
743
+ }));
744
+ return {
745
+ capitaleAliquota26,
746
+ capitaleAliquota125,
747
+ dividendiEsteri,
748
+ cedole
749
+ };
750
+ }
751
+ function buildDichiarazioneReport(quadroRT, quadroRM, taxYear) {
752
+ const version = 1;
753
+ const modello = "Redditi PF";
754
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
755
+ return {
756
+ version,
757
+ annoImposta: taxYear,
758
+ modello,
759
+ generatedAt,
760
+ quadroRT,
761
+ quadroRM,
762
+ exportTo: async (path7) => {
763
+ await writeFile(
764
+ path7,
765
+ JSON.stringify(
766
+ {
767
+ version,
768
+ annoImposta: taxYear,
769
+ modello,
770
+ generatedAt,
771
+ quadroRT,
772
+ quadroRM
773
+ },
774
+ null,
775
+ 2
776
+ )
777
+ );
778
+ }
779
+ };
780
+ }
781
+
782
+ // src/calculator/index.ts
783
+ function roundHalfUp2(x) {
784
+ return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
785
+ }
439
786
  function inferTaxYear(transactions) {
440
787
  const counts = {};
441
788
  for (const t of transactions) {
@@ -453,13 +800,11 @@ function inferTaxYear(transactions) {
453
800
  var Calculator = class {
454
801
  _transactions;
455
802
  _parseWarnings;
456
- /**
457
- * @param transactions - Normalised Transaction objects from the CSV import step.
458
- * @param parseWarnings - Warnings collected during the import step; forwarded into the report.
459
- */
460
- constructor(transactions, parseWarnings) {
803
+ _options;
804
+ constructor(transactions, parseWarnings, options) {
461
805
  this._transactions = transactions;
462
806
  this._parseWarnings = parseWarnings ?? [];
807
+ this._options = options ?? {};
463
808
  }
464
809
  /**
465
810
  * Run LIFO or FIFO lot-matching over the transaction list.
@@ -529,9 +874,9 @@ var Calculator = class {
529
874
  sellDate: tx.date,
530
875
  buyPriceEUR: lot.pricePerUnitEUR,
531
876
  sellPriceEUR: sellPricePerUnitEUR,
532
- buyCostEUR: roundHalfUp(buyCostEUR),
533
- sellProceedsEUR: roundHalfUp(sellProceedsEUR),
534
- gainLossEUR: roundHalfUp(gainLossEUR),
877
+ buyCostEUR: roundHalfUp2(buyCostEUR),
878
+ sellProceedsEUR: roundHalfUp2(sellProceedsEUR),
879
+ gainLossEUR: roundHalfUp2(gainLossEUR),
535
880
  buyFxRate: lot.fxRate,
536
881
  sellFxRate: tx.fxRate
537
882
  });
@@ -553,12 +898,133 @@ var Calculator = class {
553
898
  if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
554
899
  else minusvalenze += Math.abs(lot.gainLossEUR);
555
900
  }
901
+ if (this._options.classification) {
902
+ const classification = this._options.classification;
903
+ const unclassifiedIsins = /* @__PURE__ */ new Set();
904
+ for (const lot of matchedLots) {
905
+ const entry = classification[lot.isin];
906
+ if (!entry) {
907
+ lot.bucket = "B";
908
+ unclassifiedIsins.add(lot.isin);
909
+ } else if (entry.bucketGain === "A" && lot.gainLossEUR >= 0) {
910
+ lot.bucket = "A";
911
+ } else {
912
+ lot.bucket = "B";
913
+ }
914
+ }
915
+ for (const isin of unclassifiedIsins) {
916
+ warnings.push(
917
+ `ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`
918
+ );
919
+ }
920
+ const bucketALots = matchedLots.filter((l) => l.bucket === "A");
921
+ const groupsByRate = /* @__PURE__ */ new Map();
922
+ for (const lot of bucketALots) {
923
+ const entry = classification[lot.isin];
924
+ const rate = entry.taxRate;
925
+ if (!groupsByRate.has(rate))
926
+ groupsByRate.set(rate, { assetClasses: /* @__PURE__ */ new Set(), plusvalenze: 0 });
927
+ const g = groupsByRate.get(rate);
928
+ g.assetClasses.add(entry.assetClass);
929
+ g.plusvalenze += lot.gainLossEUR;
930
+ }
931
+ const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
932
+ taxRate,
933
+ assetClasses: [...g.assetClasses],
934
+ plusvalenze: roundHalfUp2(g.plusvalenze),
935
+ imposta: roundHalfUp2(g.plusvalenze * taxRate)
936
+ }));
937
+ const bucketAReport = {
938
+ groups: bucketAGroups,
939
+ totalImposta: roundHalfUp2(
940
+ bucketAGroups.reduce((s, g) => s + g.imposta, 0)
941
+ )
942
+ };
943
+ const bucketBLots = matchedLots.filter((l) => l.bucket === "B");
944
+ let bPlusvalenze = 0;
945
+ let bMinusvalenze = 0;
946
+ for (const lot of bucketBLots) {
947
+ if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
948
+ else bMinusvalenze += Math.abs(lot.gainLossEUR);
949
+ }
950
+ bPlusvalenze = roundHalfUp2(bPlusvalenze);
951
+ bMinusvalenze = roundHalfUp2(bMinusvalenze);
952
+ const carryForwards = [...this._options.carryForward ?? []].sort(
953
+ (a, b) => a.year - b.year
954
+ );
955
+ let remaining = bPlusvalenze - bMinusvalenze;
956
+ let carryForwardApplied = 0;
957
+ for (const entry of carryForwards) {
958
+ if (taxYear - entry.year > 4) continue;
959
+ if (remaining <= 0) break;
960
+ const consumed = Math.min(entry.amount, remaining);
961
+ carryForwardApplied += consumed;
962
+ remaining -= consumed;
963
+ }
964
+ carryForwardApplied = roundHalfUp2(carryForwardApplied);
965
+ const bNetResult = roundHalfUp2(
966
+ bPlusvalenze - bMinusvalenze - carryForwardApplied
967
+ );
968
+ const carryForwardRemaining = roundHalfUp2(Math.max(0, -bNetResult));
969
+ const bucketBReport = {
970
+ plusvalenze: bPlusvalenze,
971
+ minusvalenze: bMinusvalenze,
972
+ carryForwardApplied,
973
+ carryForwardRemaining,
974
+ netResult: bNetResult
975
+ };
976
+ const allIncomeRows = this._options.incomeRows ?? [];
977
+ const filteredIncomeRows = allIncomeRows.filter(
978
+ (row) => new Date(row.date).getFullYear() === taxYear
979
+ );
980
+ if (filteredIncomeRows.length < allIncomeRows.length) {
981
+ warnings.push(`Income rows outside tax year ${taxYear} were skipped.`);
982
+ }
983
+ const quadroRT = buildQuadroRT(
984
+ bucketBReport,
985
+ this._options.carryForward ?? [],
986
+ taxYear
987
+ );
988
+ const quadroRM = buildQuadroRM(
989
+ bucketAGroups.length > 0 ? bucketAReport : void 0,
990
+ filteredIncomeRows,
991
+ taxYear
992
+ );
993
+ const dichiarazioneReport = buildDichiarazioneReport(
994
+ quadroRT,
995
+ quadroRM,
996
+ taxYear
997
+ );
998
+ return {
999
+ method,
1000
+ taxYear,
1001
+ plusvalenze: roundHalfUp2(plusvalenze),
1002
+ minusvalenze: roundHalfUp2(minusvalenze),
1003
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
1004
+ lots: matchedLots,
1005
+ ratesUsed,
1006
+ warnings,
1007
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1008
+ bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
1009
+ bucketB: bucketBReport,
1010
+ dichiarazione: dichiarazioneReport
1011
+ };
1012
+ } else {
1013
+ const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
1014
+ const hasIePrefix = allIsins.some((isin) => isin.startsWith("IE"));
1015
+ const hasOther = allIsins.some((isin) => !isin.startsWith("IE"));
1016
+ if (hasIePrefix && hasOther) {
1017
+ warnings.push(
1018
+ "AVVISO: CSV contiene tipi di strumenti misti (es. ETF + Azioni).\n Il calcolo a bucket unico pu\xF2 non essere fiscalmente corretto.\n Esegui: minus-tracker classify trades.csv"
1019
+ );
1020
+ }
1021
+ }
556
1022
  return {
557
1023
  method,
558
1024
  taxYear,
559
- plusvalenze: roundHalfUp(plusvalenze),
560
- minusvalenze: roundHalfUp(minusvalenze),
561
- netResult: roundHalfUp(plusvalenze - minusvalenze),
1025
+ plusvalenze: roundHalfUp2(plusvalenze),
1026
+ minusvalenze: roundHalfUp2(minusvalenze),
1027
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
562
1028
  lots: matchedLots,
563
1029
  ratesUsed,
564
1030
  warnings,
@@ -567,6 +1033,352 @@ var Calculator = class {
567
1033
  }
568
1034
  };
569
1035
 
1036
+ // src/classifier/index.ts
1037
+ import * as fs3 from "fs";
1038
+ import * as https from "https";
1039
+ function httpsPost(url, body, timeoutMs) {
1040
+ return new Promise((resolve2, reject) => {
1041
+ const u = new URL(url);
1042
+ const req = https.request(
1043
+ {
1044
+ hostname: u.hostname,
1045
+ path: u.pathname + u.search,
1046
+ method: "POST",
1047
+ headers: {
1048
+ "Content-Type": "application/json",
1049
+ "Content-Length": Buffer.byteLength(body)
1050
+ }
1051
+ },
1052
+ (res) => {
1053
+ let data = "";
1054
+ res.on("data", (chunk) => {
1055
+ data += chunk;
1056
+ });
1057
+ res.on("end", () => resolve2({ status: res.statusCode ?? 0, data }));
1058
+ }
1059
+ );
1060
+ req.setTimeout(timeoutMs, () => {
1061
+ req.destroy();
1062
+ reject(new Error("timeout"));
1063
+ });
1064
+ req.on("error", reject);
1065
+ req.write(body);
1066
+ req.end();
1067
+ });
1068
+ }
1069
+ var GOVT_BOND_WHITELIST = /* @__PURE__ */ new Set([
1070
+ "IT",
1071
+ "DE",
1072
+ "FR",
1073
+ "AT",
1074
+ "BE",
1075
+ "NL",
1076
+ "ES",
1077
+ "PT",
1078
+ "FI",
1079
+ "IE",
1080
+ "LU",
1081
+ "GR",
1082
+ "SK",
1083
+ "SI",
1084
+ "LT",
1085
+ "LV",
1086
+ "EE",
1087
+ "MT",
1088
+ "CY",
1089
+ "US",
1090
+ "GB",
1091
+ "CH",
1092
+ "NO",
1093
+ "SE",
1094
+ "DK",
1095
+ "JP",
1096
+ "CA",
1097
+ "AU",
1098
+ "NZ",
1099
+ "SG",
1100
+ "HK",
1101
+ "KR"
1102
+ ]);
1103
+ var SECURITY_TYPE_MAP = {
1104
+ ETP: {
1105
+ assetClass: "ETF",
1106
+ bucketGain: "A",
1107
+ bucketLoss: "B",
1108
+ taxRate: 0.26,
1109
+ whiteListed: null
1110
+ },
1111
+ ETF: {
1112
+ assetClass: "ETF",
1113
+ bucketGain: "A",
1114
+ bucketLoss: "B",
1115
+ taxRate: 0.26,
1116
+ whiteListed: null
1117
+ },
1118
+ "Mutual Fund": {
1119
+ assetClass: "ETF",
1120
+ bucketGain: "A",
1121
+ bucketLoss: "B",
1122
+ taxRate: 0.26,
1123
+ whiteListed: null
1124
+ },
1125
+ "Open-End Fund": {
1126
+ assetClass: "ETF",
1127
+ bucketGain: "A",
1128
+ bucketLoss: "B",
1129
+ taxRate: 0.26,
1130
+ whiteListed: null
1131
+ },
1132
+ "Common Stock": {
1133
+ assetClass: "Stock",
1134
+ bucketGain: "B",
1135
+ bucketLoss: "B",
1136
+ taxRate: 0,
1137
+ whiteListed: null
1138
+ },
1139
+ Equity: {
1140
+ assetClass: "Stock",
1141
+ bucketGain: "B",
1142
+ bucketLoss: "B",
1143
+ taxRate: 0,
1144
+ whiteListed: null
1145
+ },
1146
+ "Exchange Traded Commodity": {
1147
+ assetClass: "ETC",
1148
+ bucketGain: "B",
1149
+ bucketLoss: "B",
1150
+ taxRate: 0,
1151
+ whiteListed: null
1152
+ },
1153
+ "Corporate Bond": {
1154
+ assetClass: "CorpBond",
1155
+ bucketGain: "B",
1156
+ bucketLoss: "B",
1157
+ taxRate: 0,
1158
+ whiteListed: null
1159
+ },
1160
+ Option: {
1161
+ assetClass: "Derivative",
1162
+ bucketGain: "B",
1163
+ bucketLoss: "B",
1164
+ taxRate: 0,
1165
+ whiteListed: null
1166
+ },
1167
+ Future: {
1168
+ assetClass: "Derivative",
1169
+ bucketGain: "B",
1170
+ bucketLoss: "B",
1171
+ taxRate: 0,
1172
+ whiteListed: null
1173
+ },
1174
+ Warrant: {
1175
+ assetClass: "Derivative",
1176
+ bucketGain: "B",
1177
+ bucketLoss: "B",
1178
+ taxRate: 0,
1179
+ whiteListed: null
1180
+ },
1181
+ CFD: {
1182
+ assetClass: "Derivative",
1183
+ bucketGain: "B",
1184
+ bucketLoss: "B",
1185
+ taxRate: 0,
1186
+ whiteListed: null
1187
+ },
1188
+ Swap: {
1189
+ assetClass: "Derivative",
1190
+ bucketGain: "B",
1191
+ bucketLoss: "B",
1192
+ taxRate: 0,
1193
+ whiteListed: null
1194
+ },
1195
+ "Leverage Certificate": {
1196
+ assetClass: "LeverageCert",
1197
+ bucketGain: "B",
1198
+ bucketLoss: "B",
1199
+ taxRate: 0,
1200
+ whiteListed: null
1201
+ },
1202
+ "Turbo Certificate": {
1203
+ assetClass: "LeverageCert",
1204
+ bucketGain: "B",
1205
+ bucketLoss: "B",
1206
+ taxRate: 0,
1207
+ whiteListed: null
1208
+ },
1209
+ "Capital Protected Certificate": {
1210
+ assetClass: "CapProtectedCert",
1211
+ bucketGain: "A",
1212
+ bucketLoss: "B",
1213
+ taxRate: 0.26,
1214
+ whiteListed: null
1215
+ }
1216
+ };
1217
+ function isKnownType(t) {
1218
+ return t === "Government Bond" || t in SECURITY_TYPE_MAP;
1219
+ }
1220
+ function classifyByType(isin, securityType, product, warnings) {
1221
+ if (securityType === "Government Bond") {
1222
+ const prefix = isin.slice(0, 2).toUpperCase();
1223
+ const whiteListed = GOVT_BOND_WHITELIST.has(prefix);
1224
+ return {
1225
+ product,
1226
+ assetClass: whiteListed ? "GovtBondWL" : "GovtBondOther",
1227
+ bucketGain: "A",
1228
+ bucketLoss: "B",
1229
+ taxRate: whiteListed ? 0.125 : 0.26,
1230
+ whiteListed,
1231
+ confirmedByUser: false,
1232
+ source: "openfigi"
1233
+ };
1234
+ }
1235
+ const mapped = SECURITY_TYPE_MAP[securityType];
1236
+ if (mapped) {
1237
+ return {
1238
+ product,
1239
+ assetClass: mapped.assetClass,
1240
+ bucketGain: mapped.bucketGain,
1241
+ bucketLoss: mapped.bucketLoss,
1242
+ taxRate: mapped.taxRate,
1243
+ whiteListed: mapped.whiteListed,
1244
+ confirmedByUser: false,
1245
+ source: "openfigi"
1246
+ };
1247
+ }
1248
+ warnings.push(
1249
+ `Unrecognized type: ${securityType}. Please classify manually.`
1250
+ );
1251
+ return {
1252
+ product,
1253
+ assetClass: "Stock",
1254
+ bucketGain: "B",
1255
+ bucketLoss: "B",
1256
+ taxRate: 0,
1257
+ whiteListed: null,
1258
+ confirmedByUser: false,
1259
+ source: "user"
1260
+ };
1261
+ }
1262
+ var Classifier = class {
1263
+ interactive;
1264
+ constructor(options) {
1265
+ this.interactive = options?.interactive ?? true;
1266
+ }
1267
+ async load(sidecarPath) {
1268
+ if (!fs3.existsSync(sidecarPath)) {
1269
+ throw new ClassificationError("SIDECAR_NOT_FOUND");
1270
+ }
1271
+ let parsed;
1272
+ try {
1273
+ const raw = fs3.readFileSync(sidecarPath, "utf-8");
1274
+ parsed = JSON.parse(raw);
1275
+ } catch {
1276
+ throw new ClassificationError("SIDECAR_MALFORMED");
1277
+ }
1278
+ if (typeof parsed !== "object" || parsed === null || parsed["version"] !== 1) {
1279
+ throw new ClassificationError("SIDECAR_VERSION");
1280
+ }
1281
+ return parsed.classifications;
1282
+ }
1283
+ async classify(transactions, sidecarPath, _httpPost = httpsPost) {
1284
+ const isinToProduct = /* @__PURE__ */ new Map();
1285
+ for (const tx of transactions) {
1286
+ if (tx.isin && !isinToProduct.has(tx.isin)) {
1287
+ isinToProduct.set(tx.isin, tx.product);
1288
+ }
1289
+ }
1290
+ const confirmed = {};
1291
+ if (fs3.existsSync(sidecarPath)) {
1292
+ const existingMap = await this.load(sidecarPath);
1293
+ for (const [isin, entry] of Object.entries(existingMap)) {
1294
+ if (entry.confirmedByUser) {
1295
+ confirmed[isin] = entry;
1296
+ }
1297
+ }
1298
+ }
1299
+ const toProcess = [];
1300
+ for (const isin of isinToProduct.keys()) {
1301
+ if (!(isin in confirmed)) {
1302
+ toProcess.push(isin);
1303
+ }
1304
+ }
1305
+ const warnings = [];
1306
+ const newEntries = {};
1307
+ const BATCH_SIZE = 10;
1308
+ for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
1309
+ if (i > 0) {
1310
+ await new Promise((resolve2) => setTimeout(resolve2, 6e3));
1311
+ }
1312
+ const batch = toProcess.slice(i, i + BATCH_SIZE);
1313
+ const requestBody = JSON.stringify(
1314
+ batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
1315
+ );
1316
+ let response = null;
1317
+ for (let attempt = 0; attempt < 2; attempt++) {
1318
+ try {
1319
+ const r = await _httpPost(
1320
+ "https://api.openfigi.com/v3/mapping",
1321
+ requestBody,
1322
+ 1e4
1323
+ );
1324
+ if (r.status < 500) {
1325
+ response = r;
1326
+ break;
1327
+ }
1328
+ } catch {
1329
+ throw new ClassificationError("NETWORK_ERROR");
1330
+ }
1331
+ }
1332
+ if (response === null) {
1333
+ throw new ClassificationError("NETWORK_ERROR");
1334
+ }
1335
+ const results = JSON.parse(response.data);
1336
+ for (let j = 0; j < batch.length; j++) {
1337
+ const isin = batch[j];
1338
+ const result = results[j];
1339
+ const product = isinToProduct.get(isin) ?? isin;
1340
+ if (!result || result.error || !result.data || result.data.length === 0) {
1341
+ warnings.push(
1342
+ `Unrecognized type: unknown. Please classify manually.`
1343
+ );
1344
+ newEntries[isin] = {
1345
+ product,
1346
+ assetClass: "Stock",
1347
+ bucketGain: "B",
1348
+ bucketLoss: "B",
1349
+ taxRate: 0,
1350
+ whiteListed: null,
1351
+ confirmedByUser: false,
1352
+ source: "user"
1353
+ };
1354
+ continue;
1355
+ }
1356
+ const st = result.data[0]?.securityType;
1357
+ const st2 = result.data[0]?.securityType2;
1358
+ const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
1359
+ newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
1360
+ }
1361
+ }
1362
+ const mergedMap = { ...newEntries, ...confirmed };
1363
+ const sidecarContent = JSON.stringify(
1364
+ {
1365
+ version: 1,
1366
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1367
+ classifications: mergedMap,
1368
+ warnings
1369
+ },
1370
+ null,
1371
+ 2
1372
+ );
1373
+ try {
1374
+ fs3.writeFileSync(sidecarPath, sidecarContent, "utf-8");
1375
+ } catch {
1376
+ throw new ClassificationError("WRITE_ERROR");
1377
+ }
1378
+ return mergedMap;
1379
+ }
1380
+ };
1381
+
570
1382
  // src/cli/renderer.ts
571
1383
  var SEPARATOR = "\u2500".repeat(72);
572
1384
  function formatEUR(amount, locale) {
@@ -579,14 +1391,99 @@ function formatGainLoss(amount, locale) {
579
1391
  const formatted = formatEUR(Math.abs(amount), locale);
580
1392
  return amount >= 0 ? `+${formatted}` : `-${formatted}`;
581
1393
  }
582
- function renderReport(report, s) {
1394
+ function assetClassLabel(ac, s) {
1395
+ if (ac === "ETF") return s.bucketAEtf;
1396
+ if (ac === "GovtBondWL" || ac === "GovtBondOther") return s.bucketABtpWl;
1397
+ return ac;
1398
+ }
1399
+ function renderDichiarazione(report, s, carryForwardWasProvided) {
1400
+ const d = report.dichiarazione;
1401
+ const rt = d.quadroRT;
1402
+ const rm = d.quadroRM;
583
1403
  const fmt = (n) => formatEUR(n, s.numberLocale);
584
1404
  const lines = [];
1405
+ lines.push(SEPARATOR);
1406
+ lines.push(s.dichiarazioneHeader(d.annoImposta));
1407
+ lines.push(s.dichiarazioneNota);
1408
+ lines.push("");
1409
+ lines.push(s.quadroRTHeader);
1410
+ lines.push(` ${s.quadroRTPlusvalenze} ${fmt(rt.plusvalenze)}`);
1411
+ lines.push(` ${s.quadroRTMinusvalenze} ${fmt(rt.minusvalenze)}`);
1412
+ lines.push(` ${s.quadroRTDifferenza} ${fmt(rt.differenza)}`);
1413
+ for (const entry of rt.carryForwardApplied) {
1414
+ lines.push(
1415
+ ` ${s.quadroRTRiporto(entry.annoOrigine)} ${fmt(entry.importo)}`
1416
+ );
1417
+ }
1418
+ let rtNLine = ` ${s.quadroRTImponibile} ${fmt(rt.imponibileNetto)}`;
1419
+ if (rt.differenza < 0) {
1420
+ rtNLine += ` (${s.quadroRTPerdita} ${fmt(Math.abs(rt.differenza))})`;
1421
+ }
1422
+ if (rt.differenza > 0 && !carryForwardWasProvided) {
1423
+ rtNLine += ` ${s.warnNoCarryForwardProvided}`;
1424
+ }
1425
+ lines.push(rtNLine);
1426
+ lines.push(` ${s.quadroRTImposta} ${fmt(rt.imposta)}`);
1427
+ if (rt.differenza < 0) {
1428
+ const riportato = rt.carryForwardRiportato.reduce(
1429
+ (sum, e) => sum + e.importo,
1430
+ 0
1431
+ );
1432
+ lines.push(
1433
+ ` ${s.quadroRTRiportabile} ${fmt(riportato)} ${s.bucketBCarryNote}`
1434
+ );
1435
+ }
1436
+ lines.push("");
1437
+ lines.push(s.quadroRMHeader);
1438
+ if (rm.capitaleAliquota26.plusvalenze > 0) {
1439
+ lines.push(
1440
+ ` ${s.quadroRMEtf26} ${fmt(rm.capitaleAliquota26.plusvalenze)}`
1441
+ );
1442
+ lines.push(
1443
+ ` [RM-A1] ${s.quadroRMImposta} ${fmt(rm.capitaleAliquota26.imposta)}`
1444
+ );
1445
+ }
1446
+ if (rm.capitaleAliquota125.plusvalenze > 0) {
1447
+ lines.push(
1448
+ ` ${s.quadroRMBtp} ${fmt(rm.capitaleAliquota125.plusvalenze)}`
1449
+ );
1450
+ lines.push(
1451
+ ` [RM-A2] ${s.quadroRMImposta} ${fmt(rm.capitaleAliquota125.imposta)}`
1452
+ );
1453
+ }
1454
+ for (const div of rm.dividendiEsteri) {
1455
+ lines.push(
1456
+ ` ${s.quadroRMDividendi} \u2014 ${div.prodotto} (${div.isin}) ${fmt(div.lordo)}`
1457
+ );
1458
+ lines.push(
1459
+ ` [RM-D] ${s.quadroRMRitenuta} \u2014 ${div.prodotto} (${div.isin}) ${fmt(div.rittenutaEstera)}`
1460
+ );
1461
+ }
1462
+ for (const c of rm.cedole) {
1463
+ lines.push(
1464
+ ` ${s.quadroRMCedole} \u2014 ${c.prodotto} (${c.isin}) ${fmt(c.importo)}`
1465
+ );
1466
+ }
1467
+ lines.push("");
1468
+ lines.push(s.dichiarazioneWarningRow);
1469
+ lines.push(s.dichiarazioneDisclaimer);
1470
+ return lines.join("\n");
1471
+ }
1472
+ function renderReport(report, s, carryForwardWasProvided) {
1473
+ const fmt = (n) => formatEUR(n, s.numberLocale);
1474
+ const lines = [];
1475
+ const hasBuckets = !!(report.bucketA || report.bucketB);
585
1476
  lines.push(
586
1477
  `${s.headerMethod}: ${report.method} | ${s.headerTaxYear}: ${report.taxYear}`
587
1478
  );
588
1479
  lines.push("");
589
- const headers = [
1480
+ for (const w of report.warnings) {
1481
+ if (w.startsWith("AVVISO:") || w.startsWith("WARNING:")) {
1482
+ lines.push(w);
1483
+ lines.push("");
1484
+ }
1485
+ }
1486
+ const headerCols = [
590
1487
  s.headerIsin.padEnd(14),
591
1488
  s.headerProduct.padEnd(20),
592
1489
  s.headerQty.padStart(5),
@@ -595,10 +1492,13 @@ function renderReport(report, s) {
595
1492
  s.headerBuyEur.padStart(14),
596
1493
  s.headerSellEur.padStart(13),
597
1494
  s.headerGainLoss.padStart(17)
598
- ].join(" ");
599
- lines.push(headers);
1495
+ ];
1496
+ if (hasBuckets) {
1497
+ headerCols.push(s.headerBucket.padStart(8));
1498
+ }
1499
+ lines.push(headerCols.join(" "));
600
1500
  for (const lot of report.lots) {
601
- const row = [
1501
+ const rowCols = [
602
1502
  lot.isin.padEnd(14),
603
1503
  lot.product.substring(0, 20).padEnd(20),
604
1504
  String(lot.quantity).padStart(5),
@@ -607,8 +1507,11 @@ function renderReport(report, s) {
607
1507
  fmt(lot.buyCostEUR).padStart(14),
608
1508
  fmt(lot.sellProceedsEUR).padStart(13),
609
1509
  formatGainLoss(lot.gainLossEUR, s.numberLocale).padStart(17)
610
- ].join(" ");
611
- lines.push(row);
1510
+ ];
1511
+ if (hasBuckets) {
1512
+ rowCols.push((lot.bucket ?? "").padStart(8));
1513
+ }
1514
+ lines.push(rowCols.join(" "));
612
1515
  }
613
1516
  lines.push("");
614
1517
  lines.push(SEPARATOR);
@@ -619,111 +1522,48 @@ function renderReport(report, s) {
619
1522
  lines.push(`${s.summaryWarnings}: ${report.warnings.length}`);
620
1523
  lines.push(`${s.summaryGenerated}: ${report.generatedAt}`);
621
1524
  lines.push("");
622
- lines.push(s.disclaimer);
623
- return lines.join("\n");
624
- }
625
-
626
- // src/cli/commands/rates.ts
627
- import * as fs3 from "fs";
628
- import * as path3 from "path";
629
- import * as os2 from "os";
630
- import * as https from "https";
631
- function getSnapshotPath() {
632
- const platform = process.platform;
633
- let configDir;
634
- if (platform === "win32") {
635
- configDir = process.env.APPDATA ?? path3.join(os2.homedir(), "AppData", "Roaming");
636
- } else {
637
- configDir = process.env.XDG_CONFIG_HOME ?? path3.join(os2.homedir(), ".config");
638
- }
639
- return path3.join(configDir, "minus-tracker", "ecb-rates.json");
640
- }
641
- function getCoverage(snapshot) {
642
- let start = "9999-12-31";
643
- let end = "0000-01-01";
644
- const currencies = [];
645
- for (const [ccy, dates] of Object.entries(snapshot)) {
646
- currencies.push(ccy);
647
- for (const d of Object.keys(dates)) {
648
- if (d < start) start = d;
649
- if (d > end) end = d;
1525
+ if (report.bucketA && report.bucketA.groups.length > 0) {
1526
+ lines.push(SEPARATOR);
1527
+ lines.push(s.bucketAHeader);
1528
+ lines.push(SEPARATOR);
1529
+ for (const g of report.bucketA.groups) {
1530
+ const label = g.assetClasses.map((ac) => assetClassLabel(ac, s)).join(", ");
1531
+ const rateDisplay = (g.taxRate * 100).toFixed(0);
1532
+ lines.push(
1533
+ `${label}: ${fmt(g.plusvalenze)} EUR \u2192 imposta: ${fmt(g.imposta)} EUR (${rateDisplay}%)`
1534
+ );
650
1535
  }
1536
+ lines.push(`${s.bucketATotalTax}: ${fmt(report.bucketA.totalImposta)} EUR`);
1537
+ lines.push("");
651
1538
  }
652
- return { start, end, currencies: currencies.sort().join(", ") };
653
- }
654
- async function fetchEcbData(currency) {
655
- return new Promise((resolve, reject) => {
656
- const url = `https://data-api.ecb.europa.eu/service/data/EXR/D.${currency}.EUR.SP00.A?format=csvdata&startPeriod=2019-01-01`;
657
- https.get(url, (res) => {
658
- let data = "";
659
- res.on("data", (chunk) => data += chunk);
660
- res.on("end", () => {
661
- const rates = {};
662
- const lines = data.split("\n");
663
- for (const line of lines) {
664
- const parts = line.split(",");
665
- if (parts.length < 8) continue;
666
- const date = parts[6].trim();
667
- const rate = parseFloat(parts[7].trim());
668
- if (/^\d{4}-\d{2}-\d{2}$/.test(date) && !isNaN(rate) && rate > 0) {
669
- rates[date] = rate;
670
- }
671
- }
672
- resolve(rates);
673
- });
674
- res.on("error", reject);
675
- }).on("error", reject);
676
- });
677
- }
678
- async function updateRates(snapshotPath, stdout, stderr, s) {
679
- stdout.write(s.ratesUpdateFetching + "\n");
680
- let existing = {};
681
- try {
682
- existing = JSON.parse(
683
- fs3.readFileSync(snapshotPath, "utf8")
684
- );
685
- } catch {
686
- }
687
- let addedCount = 0;
688
- for (const currency of ["USD", "GBP", "CHF"]) {
689
- try {
690
- const newRates = await fetchEcbData(currency);
691
- const existing_ccy = existing[currency] ?? {};
692
- let added = 0;
693
- for (const [date, rate] of Object.entries(newRates)) {
694
- if (!existing_ccy[date]) added++;
695
- existing_ccy[date] = rate;
696
- }
697
- existing[currency] = existing_ccy;
698
- addedCount += added;
699
- } catch {
700
- stderr.write(`Failed to fetch ${currency} rates
701
- `);
1539
+ if (report.bucketB) {
1540
+ lines.push(SEPARATOR);
1541
+ lines.push(s.bucketBHeader);
1542
+ lines.push(SEPARATOR);
1543
+ lines.push(`PLUSVALENZE: ${fmt(report.bucketB.plusvalenze)} EUR`);
1544
+ lines.push(`MINUSVALENZE: ${fmt(report.bucketB.minusvalenze)} EUR`);
1545
+ if (report.bucketB.carryForwardApplied > 0) {
1546
+ lines.push(
1547
+ `${s.bucketBCarryApplied(0)}: ${fmt(report.bucketB.carryForwardApplied)} EUR`
1548
+ );
702
1549
  }
1550
+ let resultLine = `${s.bucketBResult}: ${fmt(report.bucketB.netResult)} EUR`;
1551
+ if (report.bucketB.carryForwardRemaining > 0) {
1552
+ resultLine += ` ${s.bucketBCarryNote}`;
1553
+ }
1554
+ lines.push(resultLine);
1555
+ lines.push("");
703
1556
  }
704
- fs3.mkdirSync(path3.dirname(snapshotPath), { recursive: true });
705
- fs3.writeFileSync(
706
- snapshotPath,
707
- JSON.stringify(existing, null, 2) + "\n",
708
- "utf8"
709
- );
710
- stdout.write(s.ratesUpdateDone(addedCount) + "\n");
711
- stdout.write(s.ratesSnapshotWritten(snapshotPath) + "\n");
712
- }
713
- async function runRates(positional, flags, s, stdout, stderr) {
714
- if (flags["check"]) {
715
- const snapshot = getActiveSnapshot();
716
- const { start, end, currencies } = getCoverage(snapshot);
717
- stdout.write(s.ratesCoverage(start, end, currencies) + "\n");
718
- stdout.write(s.ratesGapsNone + "\n");
719
- return 0;
1557
+ if (hasBuckets) {
1558
+ lines.push(s.warnMixedBuckets);
1559
+ lines.push("");
720
1560
  }
721
- if (flags["update"]) {
722
- await updateRates(getSnapshotPath(), stdout, stderr, s);
723
- return 0;
1561
+ if (report.dichiarazione) {
1562
+ lines.push(renderDichiarazione(report, s, carryForwardWasProvided));
1563
+ } else {
1564
+ lines.push(s.disclaimer);
724
1565
  }
725
- stderr.write("Usage: minus-tracker rates --check | --update\n");
726
- return 2;
1566
+ return lines.join("\n");
727
1567
  }
728
1568
 
729
1569
  // src/cli/commands/calc.ts
@@ -743,29 +1583,63 @@ async function runCalc(positional, flags, s, stdout, stderr) {
743
1583
  `);
744
1584
  return 1;
745
1585
  }
746
- try {
747
- const snapshot = getActiveSnapshot();
748
- if (isSnapshotStale(snapshot)) {
749
- stderr.write(s.ratesAutoUpdateStart + "\n");
750
- try {
751
- await updateRates(getSnapshotPath(), stdout, stderr, s);
752
- } catch {
753
- stderr.write(s.ratesAutoUpdateFailed + "\n");
754
- }
1586
+ const method = flags["method"] ?? "LIFO";
1587
+ if (method !== "LIFO" && method !== "FIFO") {
1588
+ stderr.write("--method must be LIFO or FIFO\n");
1589
+ return 2;
1590
+ }
1591
+ const sidecarPath = filePath.replace(/\.csv$/i, "") + ".classify.json";
1592
+ let classification;
1593
+ if (fs4.existsSync(sidecarPath)) {
1594
+ try {
1595
+ const classifier = new Classifier({ interactive: false });
1596
+ classification = await classifier.load(sidecarPath);
1597
+ } catch {
1598
+ stderr.write(`Cannot load sidecar: ${sidecarPath}
1599
+ `);
1600
+ return 1;
755
1601
  }
756
- } catch {
757
- stderr.write(s.ratesAutoUpdateStart + "\n");
1602
+ }
1603
+ const exportFlagRaw = flags["export-dichiarazione"];
1604
+ const exportRequested = exportFlagRaw !== void 0 && exportFlagRaw !== false;
1605
+ const exportPath = typeof exportFlagRaw === "string" ? exportFlagRaw : filePath.replace(/\.csv$/i, "") + ".dichiarazione.json";
1606
+ if (exportRequested && classification === void 0) {
1607
+ stderr.write(s.warnNoDichiarazioneSidecar + "\n");
1608
+ return 1;
1609
+ }
1610
+ const rawCf = flags["carry-forward"];
1611
+ const rawCarryForwards = Array.isArray(rawCf) ? rawCf : rawCf ? [rawCf] : [];
1612
+ const cfRegex = /^\d{4}:\d+(\.\d+)?$/;
1613
+ for (const cf of rawCarryForwards) {
1614
+ if (!cfRegex.test(cf)) {
1615
+ stderr.write(s.carryForwardInvalidFormat + "\n");
1616
+ return 2;
1617
+ }
1618
+ }
1619
+ const xdgConfig = process.env["XDG_CONFIG_HOME"] ?? path3.join(os2.homedir(), ".config");
1620
+ const cfConfigPath = path3.join(
1621
+ xdgConfig,
1622
+ "minus-tracker",
1623
+ "carryforward.json"
1624
+ );
1625
+ const cfFromFile = {};
1626
+ if (fs4.existsSync(cfConfigPath)) {
758
1627
  try {
759
- await updateRates(getSnapshotPath(), stdout, stderr, s);
1628
+ const raw = JSON.parse(fs4.readFileSync(cfConfigPath, "utf-8"));
1629
+ for (const entry of raw.losses ?? []) {
1630
+ cfFromFile[entry.year] = entry.amount;
1631
+ }
760
1632
  } catch {
761
- stderr.write(s.ratesAutoUpdateFailed + "\n");
762
1633
  }
763
1634
  }
764
- const method = flags["method"] ?? "LIFO";
765
- if (method !== "LIFO" && method !== "FIFO") {
766
- stderr.write("--method must be LIFO or FIFO\n");
767
- return 2;
1635
+ const cfMerged = { ...cfFromFile };
1636
+ for (const cf of rawCarryForwards) {
1637
+ const [yearStr, amountStr] = cf.split(":");
1638
+ cfMerged[parseInt(yearStr, 10)] = parseFloat(amountStr);
768
1639
  }
1640
+ const carryForward = Object.entries(cfMerged).map(
1641
+ ([year, amount]) => ({ year: parseInt(year, 10), amount })
1642
+ );
769
1643
  const parser = new DEGIROParser();
770
1644
  let transactions;
771
1645
  try {
@@ -781,12 +1655,32 @@ async function runCalc(positional, flags, s, stdout, stderr) {
781
1655
  }
782
1656
  throw err;
783
1657
  }
784
- const calculator = new Calculator(transactions, parser.warnings);
1658
+ const calculator = new Calculator(transactions, parser.warnings, {
1659
+ classification,
1660
+ carryForward: carryForward.length > 0 ? carryForward : void 0,
1661
+ incomeRows: parser.incomeRows
1662
+ });
785
1663
  const report = calculator.calculateGains(method);
1664
+ const carryForwardWasProvided = carryForward.length > 0;
1665
+ if (exportRequested) {
1666
+ try {
1667
+ await report.dichiarazione.exportTo(exportPath);
1668
+ } catch {
1669
+ stderr.write(`Cannot write dichiarazione export: ${exportPath}
1670
+ `);
1671
+ return 1;
1672
+ }
1673
+ }
786
1674
  if (flags["json"]) {
787
- stdout.write(JSON.stringify(report, null, 2) + "\n");
1675
+ const { dichiarazione: _dichiarazione, ...jsonReport } = report;
1676
+ stdout.write(JSON.stringify(jsonReport, null, 2) + "\n");
788
1677
  } else {
789
- stdout.write(renderReport(report, s) + "\n");
1678
+ stdout.write(renderReport(report, s, carryForwardWasProvided) + "\n");
1679
+ }
1680
+ if (exportRequested) {
1681
+ stdout.write(s.classifyWritten(exportPath) + "\n");
1682
+ } else if (!report.dichiarazione) {
1683
+ stderr.write(s.warnNoDichiarazioneSidecar + "\n");
790
1684
  }
791
1685
  return 0;
792
1686
  }
@@ -838,12 +1732,121 @@ async function runValidate(positional, flags, s, stdout, stderr) {
838
1732
  case "QUANTITY_ZERO":
839
1733
  reason = s.warnQuantityZero(entry.row);
840
1734
  break;
1735
+ case "MISSING_ISIN_INCOME":
1736
+ reason = s.warnMissingIsinIncome(entry.row);
1737
+ break;
1738
+ case "ORPHAN_WITHHOLDING":
1739
+ reason = s.warnOrphanWithholding(entry.isin, entry.date);
1740
+ break;
841
1741
  }
842
1742
  stdout.write(reason + "\n");
843
1743
  }
844
1744
  return 0;
845
1745
  }
846
1746
 
1747
+ // src/cli/commands/rates.ts
1748
+ import * as fs6 from "fs";
1749
+ import * as path4 from "path";
1750
+ import * as os3 from "os";
1751
+ import * as https2 from "https";
1752
+ function getSnapshotPath() {
1753
+ const platform = process.platform;
1754
+ let configDir;
1755
+ if (platform === "win32") {
1756
+ configDir = process.env.APPDATA ?? path4.join(os3.homedir(), "AppData", "Roaming");
1757
+ } else {
1758
+ configDir = process.env.XDG_CONFIG_HOME ?? path4.join(os3.homedir(), ".config");
1759
+ }
1760
+ return path4.join(configDir, "minus-tracker", "ecb-rates.json");
1761
+ }
1762
+ function getCoverage(snapshot) {
1763
+ let start = "9999-12-31";
1764
+ let end = "0000-01-01";
1765
+ const currencies = [];
1766
+ for (const [ccy, dates] of Object.entries(snapshot)) {
1767
+ currencies.push(ccy);
1768
+ for (const d of Object.keys(dates)) {
1769
+ if (d < start) start = d;
1770
+ if (d > end) end = d;
1771
+ }
1772
+ }
1773
+ return { start, end, currencies: currencies.sort().join(", ") };
1774
+ }
1775
+ async function fetchEcbData(currency) {
1776
+ return new Promise((resolve2, reject) => {
1777
+ const url = `https://data-api.ecb.europa.eu/service/data/EXR/D.${currency}.EUR.SP00.A?format=csvdata&startPeriod=2019-01-01`;
1778
+ https2.get(url, (res) => {
1779
+ let data = "";
1780
+ res.on("data", (chunk) => data += chunk);
1781
+ res.on("end", () => {
1782
+ const rates = {};
1783
+ const lines = data.split("\n");
1784
+ for (const line of lines) {
1785
+ const parts = line.split(",");
1786
+ if (parts.length < 8) continue;
1787
+ const date = parts[6].trim();
1788
+ const rate = parseFloat(parts[7].trim());
1789
+ if (/^\d{4}-\d{2}-\d{2}$/.test(date) && !isNaN(rate) && rate > 0) {
1790
+ rates[date] = rate;
1791
+ }
1792
+ }
1793
+ resolve2(rates);
1794
+ });
1795
+ res.on("error", reject);
1796
+ }).on("error", reject);
1797
+ });
1798
+ }
1799
+ async function updateRates(snapshotPath, stdout, stderr, s) {
1800
+ stdout.write(s.ratesUpdateFetching + "\n");
1801
+ let existing = {};
1802
+ try {
1803
+ existing = JSON.parse(
1804
+ fs6.readFileSync(snapshotPath, "utf8")
1805
+ );
1806
+ } catch {
1807
+ }
1808
+ let addedCount = 0;
1809
+ for (const currency of ["USD", "GBP", "CHF"]) {
1810
+ try {
1811
+ const newRates = await fetchEcbData(currency);
1812
+ const existing_ccy = existing[currency] ?? {};
1813
+ let added = 0;
1814
+ for (const [date, rate] of Object.entries(newRates)) {
1815
+ if (!existing_ccy[date]) added++;
1816
+ existing_ccy[date] = rate;
1817
+ }
1818
+ existing[currency] = existing_ccy;
1819
+ addedCount += added;
1820
+ } catch {
1821
+ stderr.write(`Failed to fetch ${currency} rates
1822
+ `);
1823
+ }
1824
+ }
1825
+ fs6.mkdirSync(path4.dirname(snapshotPath), { recursive: true });
1826
+ fs6.writeFileSync(
1827
+ snapshotPath,
1828
+ JSON.stringify(existing, null, 2) + "\n",
1829
+ "utf8"
1830
+ );
1831
+ stdout.write(s.ratesUpdateDone(addedCount) + "\n");
1832
+ stdout.write(s.ratesSnapshotWritten(snapshotPath) + "\n");
1833
+ }
1834
+ async function runRates(positional, flags, s, stdout, stderr) {
1835
+ if (flags["check"]) {
1836
+ const snapshot = getActiveSnapshot();
1837
+ const { start, end, currencies } = getCoverage(snapshot);
1838
+ stdout.write(s.ratesCoverage(start, end, currencies) + "\n");
1839
+ stdout.write(s.ratesGapsNone + "\n");
1840
+ return 0;
1841
+ }
1842
+ if (flags["update"]) {
1843
+ await updateRates(getSnapshotPath(), stdout, stderr, s);
1844
+ return 0;
1845
+ }
1846
+ stderr.write("Usage: minus-tracker rates --check | --update\n");
1847
+ return 2;
1848
+ }
1849
+
847
1850
  // src/cli/commands/config.ts
848
1851
  var SUPPORTED_LOCALES2 = ["it", "en"];
849
1852
  async function runConfig(positional, flags, s, stdout, stderr) {
@@ -868,9 +1871,9 @@ async function runConfig(positional, flags, s, stdout, stderr) {
868
1871
  }
869
1872
 
870
1873
  // src/cli/commands/stress-test.ts
871
- import * as fs6 from "fs";
872
- import * as path4 from "path";
873
- import * as os3 from "os";
1874
+ import * as fs7 from "fs";
1875
+ import * as path5 from "path";
1876
+ import * as os4 from "os";
874
1877
  import { fileURLToPath as fileURLToPath2 } from "url";
875
1878
  import { spawnSync as spawnSync2 } from "child_process";
876
1879
 
@@ -948,8 +1951,8 @@ function generateCsv(scenario) {
948
1951
 
949
1952
  // src/stress/runner.ts
950
1953
  import { spawnSync } from "child_process";
951
- import { writeFileSync as writeFileSync3 } from "fs";
952
- import { join as join4 } from "path";
1954
+ import { writeFileSync as writeFileSync4 } from "fs";
1955
+ import { join as join5 } from "path";
953
1956
  function countWarnings(text) {
954
1957
  if (!text) return 0;
955
1958
  const match = text.match(/(?:warnings|avvertenze)\s*:\s*(\d+)/i);
@@ -1002,8 +2005,8 @@ function runCommand(cliBin, args, cmdLabel, expectedExitCode, outputShapeCheck)
1002
2005
  }
1003
2006
  function runScenario(scenario, csvContent, outputDir, cliBin) {
1004
2007
  const csvFileName = `${scenario.id}-${scenario.slug}.csv`;
1005
- const csvFile = join4(outputDir, csvFileName);
1006
- writeFileSync3(csvFile, csvContent, "utf8");
2008
+ const csvFile = join5(outputDir, csvFileName);
2009
+ writeFileSync4(csvFile, csvContent, "utf8");
1007
2010
  const results = [];
1008
2011
  results.push(
1009
2012
  runCommand(
@@ -1213,11 +2216,11 @@ async function runStressTest(_positionals, flags, stdout, stderr) {
1213
2216
  );
1214
2217
  return 2;
1215
2218
  }
1216
- const __dirname = path4.dirname(fileURLToPath2(import.meta.url));
1217
- const manifestPath = path4.join(__dirname, "../data/stress-manifest.json");
2219
+ const __dirname = path5.dirname(fileURLToPath2(import.meta.url));
2220
+ const manifestPath = path5.join(__dirname, "../data/stress-manifest.json");
1218
2221
  let manifest;
1219
2222
  try {
1220
- const raw = fs6.readFileSync(manifestPath, "utf8");
2223
+ const raw = fs7.readFileSync(manifestPath, "utf8");
1221
2224
  manifest = JSON.parse(raw);
1222
2225
  } catch {
1223
2226
  stderr.write(`Error: could not load stress manifest at ${manifestPath}
@@ -1229,9 +2232,9 @@ async function runStressTest(_positionals, flags, stdout, stderr) {
1229
2232
  const outputDirFlag = flags["output-dir"];
1230
2233
  if (outputDirFlag) {
1231
2234
  outputDir = outputDirFlag;
1232
- fs6.mkdirSync(outputDir, { recursive: true });
2235
+ fs7.mkdirSync(outputDir, { recursive: true });
1233
2236
  } else {
1234
- outputDir = fs6.mkdtempSync(path4.join(os3.tmpdir(), "minus-tracker-stress-"));
2237
+ outputDir = fs7.mkdtempSync(path5.join(os4.tmpdir(), "minus-tracker-stress-"));
1235
2238
  }
1236
2239
  const ratesCheck = spawnSync2("node", [cliBin, "rates", "--check"], {
1237
2240
  encoding: "utf8",
@@ -1268,11 +2271,122 @@ async function runStressTest(_positionals, flags, stdout, stderr) {
1268
2271
  stdout.write(formatTable(report) + "\n");
1269
2272
  }
1270
2273
  if (!flags["keep"]) {
1271
- fs6.rmSync(outputDir, { recursive: true, force: true });
2274
+ fs7.rmSync(outputDir, { recursive: true, force: true });
1272
2275
  }
1273
2276
  return report.failed === 0 ? 0 : 1;
1274
2277
  }
1275
2278
 
2279
+ // src/cli/commands/classify.ts
2280
+ import * as fs8 from "fs";
2281
+ import * as path6 from "path";
2282
+ async function runClassify(positional, flags, s, stdout, stderr) {
2283
+ const offline = Boolean(flags["offline"]);
2284
+ if (!process.stdin.isTTY && !offline) {
2285
+ stderr.write(s.classifyNonTtyError + "\n");
2286
+ return 2;
2287
+ }
2288
+ const csvPath = positional[0];
2289
+ if (!csvPath) {
2290
+ stderr.write("Usage: minus-tracker classify [--offline] <file.csv>\n");
2291
+ return 2;
2292
+ }
2293
+ let csv;
2294
+ try {
2295
+ csv = fs8.readFileSync(csvPath, "utf8");
2296
+ } catch {
2297
+ stderr.write(`Cannot read file: ${csvPath}
2298
+ `);
2299
+ return 1;
2300
+ }
2301
+ const parser = new DEGIROParser();
2302
+ let transactions;
2303
+ try {
2304
+ transactions = parser.parse(csv);
2305
+ } catch (err) {
2306
+ if (err instanceof ParseError) {
2307
+ if (err.code === "INVALID_CSV") {
2308
+ stderr.write(s.errorInvalidCsv + "\n");
2309
+ } else {
2310
+ stderr.write(s.errorMissingColumn(err.columnName) + "\n");
2311
+ }
2312
+ return 1;
2313
+ }
2314
+ throw err;
2315
+ }
2316
+ const base = csvPath.replace(/\.csv$/i, "");
2317
+ const sidecarPath = base + ".classify.json";
2318
+ if (offline) {
2319
+ stdout.write(s.classifyOfflineWarning + "\n");
2320
+ const isinToProduct = /* @__PURE__ */ new Map();
2321
+ for (const tx of transactions) {
2322
+ if (tx.isin && !isinToProduct.has(tx.isin)) {
2323
+ isinToProduct.set(tx.isin, tx.product);
2324
+ }
2325
+ }
2326
+ const classifications = {};
2327
+ for (const [isin, product] of isinToProduct.entries()) {
2328
+ classifications[isin] = {
2329
+ product,
2330
+ assetClass: "ETF",
2331
+ bucketGain: "A",
2332
+ bucketLoss: "B",
2333
+ taxRate: 0.26,
2334
+ whiteListed: null,
2335
+ confirmedByUser: true,
2336
+ source: "user"
2337
+ };
2338
+ }
2339
+ const sidecarContent = JSON.stringify(
2340
+ {
2341
+ version: 1,
2342
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2343
+ classifications,
2344
+ warnings: []
2345
+ },
2346
+ null,
2347
+ 2
2348
+ );
2349
+ try {
2350
+ fs8.writeFileSync(sidecarPath, sidecarContent, "utf-8");
2351
+ } catch {
2352
+ stderr.write(`Cannot write sidecar: ${sidecarPath}
2353
+ `);
2354
+ return 1;
2355
+ }
2356
+ stdout.write(s.classifyWritten(sidecarPath) + "\n");
2357
+ return 0;
2358
+ }
2359
+ const classifier = new Classifier({ interactive: true });
2360
+ if (fs8.existsSync(sidecarPath)) {
2361
+ const existingMap = await classifier.load(sidecarPath);
2362
+ const confirmedCount = Object.values(existingMap).filter(
2363
+ (e) => e.confirmedByUser
2364
+ ).length;
2365
+ stdout.write(s.classifyMergePrompt(confirmedCount));
2366
+ const answer = await readLine(process.stdin);
2367
+ if (answer.trim().toLowerCase() === "n") {
2368
+ return 0;
2369
+ }
2370
+ }
2371
+ await classifier.classify(transactions, sidecarPath);
2372
+ stdout.write(s.classifyWritten(path6.resolve(sidecarPath)) + "\n");
2373
+ return 0;
2374
+ }
2375
+ function readLine(stream) {
2376
+ return new Promise((resolve2) => {
2377
+ let buf = "";
2378
+ const onData = (chunk) => {
2379
+ buf += chunk.toString();
2380
+ const nl = buf.indexOf("\n");
2381
+ if (nl !== -1) {
2382
+ stream.removeListener("data", onData);
2383
+ resolve2(buf.slice(0, nl));
2384
+ }
2385
+ };
2386
+ stream.on("data", onData);
2387
+ });
2388
+ }
2389
+
1276
2390
  // src/cli/index.ts
1277
2391
  async function main() {
1278
2392
  const { values, positionals } = parseArgs({
@@ -1287,7 +2401,8 @@ async function main() {
1287
2401
  show: { type: "boolean", default: false },
1288
2402
  range: { type: "string" },
1289
2403
  keep: { type: "boolean", default: false },
1290
- "output-dir": { type: "string" }
2404
+ "output-dir": { type: "string" },
2405
+ offline: { type: "boolean", default: false }
1291
2406
  },
1292
2407
  allowPositionals: true,
1293
2408
  strict: false
@@ -1317,14 +2432,20 @@ async function main() {
1317
2432
  case "stress-test":
1318
2433
  exitCode = await runStressTest(restPositionals, flags, stdout, stderr);
1319
2434
  break;
2435
+ case "classify":
2436
+ exitCode = await runClassify(restPositionals, flags, s, stdout, stderr);
2437
+ break;
1320
2438
  default:
1321
2439
  stderr.write(
1322
- "Usage: minus-tracker <calc|validate|rates|config|stress-test> [options] [file]\n"
2440
+ "Usage: minus-tracker <calc|validate|rates|config|stress-test|classify> [options] [file]\n"
1323
2441
  );
1324
2442
  exitCode = 2;
1325
2443
  }
1326
2444
  } catch (err) {
1327
- if (err instanceof ParseError) {
2445
+ if (err instanceof ClassificationError) {
2446
+ stderr.write(err.message + "\n");
2447
+ exitCode = 1;
2448
+ } else if (err instanceof ParseError) {
1328
2449
  if (err.code === "INVALID_CSV") {
1329
2450
  stderr.write(s.errorInvalidCsv + "\n");
1330
2451
  } else {