@gabrielerandelli/minus-tracker 0.5.7 → 0.6.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
@@ -36,11 +36,34 @@ var it = {
36
36
  ratesGaps: (list) => `Lacune: ${list}`,
37
37
  ratesUpdateFetching: "Recupero dati BCE SDMX in corso...",
38
38
  ratesUpdateDone: (n) => `Completato. Aggiunte ${n} nuove date.`,
39
- ratesSnapshotWritten: (path4) => `Snapshot scritto in ${path4}`,
39
+ ratesSnapshotWritten: (path7) => `Snapshot scritto in ${path7}`,
40
40
  ratesAutoUpdateStart: "Le rate BCE non sono aggiornate. Aggiornamento automatico in corso...",
41
41
  ratesAutoUpdateFailed: "Aggiornamento automatico fallito. Si procede con le rate disponibili.",
42
42
  configLangSet: (lang) => `Lingua impostata su: ${lang}`,
43
43
  configCurrentLang: (lang) => `Lingua corrente: ${lang}`,
44
+ classifyStarting: (n) => `Classificazione ISIN in corso... (${n} strumenti)`,
45
+ classifyDetected: (isin, product, bucket, rate) => `Rilevato: ${product} (${isin}) \u2192 ${bucket} | ${rate}`,
46
+ classifyConfirm: "[Y/n/?]: ",
47
+ classifyOverridePrompt: "Tipo di strumento:",
48
+ classifyDone: (confirmed, total) => `Classificazione completata (${confirmed}/${total} confermati).`,
49
+ classifyWritten: (path7) => `Scritto: ${path7}`,
50
+ classifyMergePrompt: (existing) => `File gi\xE0 esistente (${existing} ISIN confermati). Aggiungere i nuovi ISIN? [Y/n]: `,
51
+ classifyOfflineWarning: "Modalit\xE0 offline: ricerca OpenFIGI saltata.",
52
+ classifyUnknownType: (type) => `Tipo non riconosciuto: ${type}. Classificare manualmente.`,
53
+ classifyNonTtyError: "Modalit\xE0 interattiva richiede un terminale (TTY). Usa --offline per classificare senza terminale.",
54
+ bucketAHeader: "BUCKET A \u2014 REDDITI DA CAPITALE (non compensabili)",
55
+ bucketBHeader: "BUCKET B \u2014 REDDITI DIVERSI",
56
+ bucketAEtf: "ETF (OICR)",
57
+ bucketABtpWl: "BTP/WL",
58
+ bucketATotalTax: "TOTALE IMPOSTA",
59
+ bucketBCarryApplied: (year) => `RIPORTO ${year}`,
60
+ bucketBResult: "RISULTATO",
61
+ bucketBCarryNote: "(riportabile ai prossimi 4 anni)",
62
+ warnMixedBuckets: "Le perdite in Bucket B non compensano le plusvalenze in Bucket A.",
63
+ 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",
64
+ headerBucket: "BUCKET",
65
+ warnUnclassifiedIsin: (isin) => `ISIN ${isin} non trovato nella mappa di classificazione \u2014 assegnato a Bucket B.`,
66
+ carryForwardInvalidFormat: "Formato --carry-forward non valido. Usa: AAAA:importo (es. 2023:2500)",
44
67
  disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale."
45
68
  };
46
69
 
@@ -77,11 +100,34 @@ var en = {
77
100
  ratesGaps: (list) => `Gaps: ${list}`,
78
101
  ratesUpdateFetching: "Fetching ECB SDMX...",
79
102
  ratesUpdateDone: (n) => `Done. Added ${n} new dates.`,
80
- ratesSnapshotWritten: (path4) => `Snapshot written to ${path4}`,
103
+ ratesSnapshotWritten: (path7) => `Snapshot written to ${path7}`,
81
104
  ratesAutoUpdateStart: "ECB rates are stale. Auto-updating\u2026",
82
105
  ratesAutoUpdateFailed: "Auto-update failed. Proceeding with available rates.",
83
106
  configLangSet: (lang) => `Language set to: ${lang}`,
84
107
  configCurrentLang: (lang) => `Current language: ${lang}`,
108
+ classifyStarting: (n) => `Classifying ISINs... (${n} instruments)`,
109
+ classifyDetected: (isin, product, bucket, rate) => `Detected: ${product} (${isin}) \u2192 ${bucket} | ${rate}`,
110
+ classifyConfirm: "[Y/n/?]: ",
111
+ classifyOverridePrompt: "Instrument type:",
112
+ classifyDone: (confirmed, total) => `Classification complete (${confirmed}/${total} confirmed).`,
113
+ classifyWritten: (path7) => `Written: ${path7}`,
114
+ classifyMergePrompt: (existing) => `File already exists (${existing} confirmed ISINs). Add new ISINs? [Y/n]: `,
115
+ classifyOfflineWarning: "Offline mode: OpenFIGI lookup skipped.",
116
+ classifyUnknownType: (type) => `Unrecognized type: ${type}. Please classify manually.`,
117
+ classifyNonTtyError: "Interactive mode requires a TTY. Use --offline to classify without a terminal.",
118
+ bucketAHeader: "BUCKET A \u2014 CAPITAL INCOME (non-offsettable)",
119
+ bucketBHeader: "BUCKET B \u2014 MISCELLANEOUS INCOME",
120
+ bucketAEtf: "ETF (OICR)",
121
+ bucketABtpWl: "Govt Bond WL",
122
+ bucketATotalTax: "TOTAL TAX",
123
+ bucketBCarryApplied: (year) => `CARRY ${year}`,
124
+ bucketBResult: "RESULT",
125
+ bucketBCarryNote: "(carriable over the next 4 years)",
126
+ warnMixedBuckets: "Bucket B losses do not offset Bucket A plusvalenze.",
127
+ 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",
128
+ headerBucket: "BUCKET",
129
+ warnUnclassifiedIsin: (isin) => `ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`,
130
+ carryForwardInvalidFormat: "Invalid --carry-forward format. Use: YYYY:amount (e.g. 2023:2500)",
85
131
  disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale."
86
132
  };
87
133
 
@@ -165,9 +211,21 @@ var CalculationError = class extends Error {
165
211
  this.date = date;
166
212
  }
167
213
  };
214
+ var ClassificationError = class extends Error {
215
+ code;
216
+ constructor(code, message) {
217
+ super(
218
+ 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")
219
+ );
220
+ this.name = "ClassificationError";
221
+ this.code = code;
222
+ }
223
+ };
168
224
 
169
225
  // src/cli/commands/calc.ts
170
- import * as fs4 from "fs";
226
+ import * as fs5 from "fs";
227
+ import * as path4 from "path";
228
+ import * as os3 from "os";
171
229
 
172
230
  // src/rates/index.ts
173
231
  import { fileURLToPath } from "url";
@@ -319,6 +377,17 @@ var DEGIROParser = class {
319
377
  constructor(snapshot) {
320
378
  this._snapshot = snapshot;
321
379
  }
380
+ /**
381
+ * Parse a DEGIRO Transactions CSV export string.
382
+ *
383
+ * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
384
+ * @returns Array of normalised Transaction objects. Empty if no data rows are found.
385
+ * @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
386
+ * @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
387
+ *
388
+ * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
389
+ * 3 trading days are skipped silently. Inspect `parser.warnings` for details.
390
+ */
322
391
  parse(csv) {
323
392
  this._warningEntries = [];
324
393
  if (typeof csv !== "string" || csv.includes("\0")) {
@@ -442,10 +511,21 @@ function inferTaxYear(transactions) {
442
511
  var Calculator = class {
443
512
  _transactions;
444
513
  _parseWarnings;
445
- constructor(transactions, parseWarnings) {
514
+ _options;
515
+ constructor(transactions, parseWarnings, options) {
446
516
  this._transactions = transactions;
447
517
  this._parseWarnings = parseWarnings ?? [];
518
+ this._options = options ?? {};
448
519
  }
520
+ /**
521
+ * Run LIFO or FIFO lot-matching over the transaction list.
522
+ *
523
+ * @param method - `"LIFO"` or `"FIFO"`.
524
+ * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
525
+ * ECB rates used, and any accumulated warnings.
526
+ * @throws {CalculationError} when a SELL has no matching open buy lots.
527
+ * `error.isin` and `error.date` identify the problematic transaction.
528
+ */
449
529
  calculateGains(method) {
450
530
  const warnings = [...this._parseWarnings];
451
531
  const sorted = [...this._transactions].sort((a, b) => {
@@ -529,6 +609,104 @@ var Calculator = class {
529
609
  if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
530
610
  else minusvalenze += Math.abs(lot.gainLossEUR);
531
611
  }
612
+ if (this._options.classification) {
613
+ const classification = this._options.classification;
614
+ const unclassifiedIsins = /* @__PURE__ */ new Set();
615
+ for (const lot of matchedLots) {
616
+ const entry = classification[lot.isin];
617
+ if (!entry) {
618
+ lot.bucket = "B";
619
+ unclassifiedIsins.add(lot.isin);
620
+ } else if (entry.bucketGain === "A" && lot.gainLossEUR >= 0) {
621
+ lot.bucket = "A";
622
+ } else {
623
+ lot.bucket = "B";
624
+ }
625
+ }
626
+ for (const isin of unclassifiedIsins) {
627
+ warnings.push(
628
+ `ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`
629
+ );
630
+ }
631
+ const bucketALots = matchedLots.filter((l) => l.bucket === "A");
632
+ const groupsByRate = /* @__PURE__ */ new Map();
633
+ for (const lot of bucketALots) {
634
+ const entry = classification[lot.isin];
635
+ const rate = entry.taxRate;
636
+ if (!groupsByRate.has(rate))
637
+ groupsByRate.set(rate, { assetClasses: /* @__PURE__ */ new Set(), plusvalenze: 0 });
638
+ const g = groupsByRate.get(rate);
639
+ g.assetClasses.add(entry.assetClass);
640
+ g.plusvalenze += lot.gainLossEUR;
641
+ }
642
+ const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
643
+ taxRate,
644
+ assetClasses: [...g.assetClasses],
645
+ plusvalenze: roundHalfUp(g.plusvalenze),
646
+ imposta: roundHalfUp(g.plusvalenze * taxRate)
647
+ }));
648
+ const bucketAReport = {
649
+ groups: bucketAGroups,
650
+ totalImposta: roundHalfUp(
651
+ bucketAGroups.reduce((s, g) => s + g.imposta, 0)
652
+ )
653
+ };
654
+ const bucketBLots = matchedLots.filter((l) => l.bucket === "B");
655
+ let bPlusvalenze = 0;
656
+ let bMinusvalenze = 0;
657
+ for (const lot of bucketBLots) {
658
+ if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
659
+ else bMinusvalenze += Math.abs(lot.gainLossEUR);
660
+ }
661
+ bPlusvalenze = roundHalfUp(bPlusvalenze);
662
+ bMinusvalenze = roundHalfUp(bMinusvalenze);
663
+ const carryForwards = [...this._options.carryForward ?? []].sort(
664
+ (a, b) => a.year - b.year
665
+ );
666
+ let remaining = bPlusvalenze - bMinusvalenze;
667
+ let carryForwardApplied = 0;
668
+ for (const entry of carryForwards) {
669
+ if (taxYear - entry.year > 4) continue;
670
+ if (remaining <= 0) break;
671
+ const consumed = Math.min(entry.amount, remaining);
672
+ carryForwardApplied += consumed;
673
+ remaining -= consumed;
674
+ }
675
+ carryForwardApplied = roundHalfUp(carryForwardApplied);
676
+ const bNetResult = roundHalfUp(
677
+ bPlusvalenze - bMinusvalenze - carryForwardApplied
678
+ );
679
+ const carryForwardRemaining = roundHalfUp(Math.max(0, -bNetResult));
680
+ const bucketBReport = {
681
+ plusvalenze: bPlusvalenze,
682
+ minusvalenze: bMinusvalenze,
683
+ carryForwardApplied,
684
+ carryForwardRemaining,
685
+ netResult: bNetResult
686
+ };
687
+ return {
688
+ method,
689
+ taxYear,
690
+ plusvalenze: roundHalfUp(plusvalenze),
691
+ minusvalenze: roundHalfUp(minusvalenze),
692
+ netResult: roundHalfUp(plusvalenze - minusvalenze),
693
+ lots: matchedLots,
694
+ ratesUsed,
695
+ warnings,
696
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
697
+ bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
698
+ bucketB: bucketBReport
699
+ };
700
+ } else {
701
+ const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
702
+ const hasIePrefix = allIsins.some((isin) => isin.startsWith("IE"));
703
+ const hasOther = allIsins.some((isin) => !isin.startsWith("IE"));
704
+ if (hasIePrefix && hasOther) {
705
+ warnings.push(
706
+ "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"
707
+ );
708
+ }
709
+ }
532
710
  return {
533
711
  method,
534
712
  taxYear,
@@ -543,6 +721,352 @@ var Calculator = class {
543
721
  }
544
722
  };
545
723
 
724
+ // src/classifier/index.ts
725
+ import * as fs3 from "fs";
726
+ import * as https from "https";
727
+ function httpsPost(url, body, timeoutMs) {
728
+ return new Promise((resolve2, reject) => {
729
+ const u = new URL(url);
730
+ const req = https.request(
731
+ {
732
+ hostname: u.hostname,
733
+ path: u.pathname + u.search,
734
+ method: "POST",
735
+ headers: {
736
+ "Content-Type": "application/json",
737
+ "Content-Length": Buffer.byteLength(body)
738
+ }
739
+ },
740
+ (res) => {
741
+ let data = "";
742
+ res.on("data", (chunk) => {
743
+ data += chunk;
744
+ });
745
+ res.on("end", () => resolve2({ status: res.statusCode ?? 0, data }));
746
+ }
747
+ );
748
+ req.setTimeout(timeoutMs, () => {
749
+ req.destroy();
750
+ reject(new Error("timeout"));
751
+ });
752
+ req.on("error", reject);
753
+ req.write(body);
754
+ req.end();
755
+ });
756
+ }
757
+ var GOVT_BOND_WHITELIST = /* @__PURE__ */ new Set([
758
+ "IT",
759
+ "DE",
760
+ "FR",
761
+ "AT",
762
+ "BE",
763
+ "NL",
764
+ "ES",
765
+ "PT",
766
+ "FI",
767
+ "IE",
768
+ "LU",
769
+ "GR",
770
+ "SK",
771
+ "SI",
772
+ "LT",
773
+ "LV",
774
+ "EE",
775
+ "MT",
776
+ "CY",
777
+ "US",
778
+ "GB",
779
+ "CH",
780
+ "NO",
781
+ "SE",
782
+ "DK",
783
+ "JP",
784
+ "CA",
785
+ "AU",
786
+ "NZ",
787
+ "SG",
788
+ "HK",
789
+ "KR"
790
+ ]);
791
+ var SECURITY_TYPE_MAP = {
792
+ ETP: {
793
+ assetClass: "ETF",
794
+ bucketGain: "A",
795
+ bucketLoss: "B",
796
+ taxRate: 0.26,
797
+ whiteListed: null
798
+ },
799
+ ETF: {
800
+ assetClass: "ETF",
801
+ bucketGain: "A",
802
+ bucketLoss: "B",
803
+ taxRate: 0.26,
804
+ whiteListed: null
805
+ },
806
+ "Mutual Fund": {
807
+ assetClass: "ETF",
808
+ bucketGain: "A",
809
+ bucketLoss: "B",
810
+ taxRate: 0.26,
811
+ whiteListed: null
812
+ },
813
+ "Open-End Fund": {
814
+ assetClass: "ETF",
815
+ bucketGain: "A",
816
+ bucketLoss: "B",
817
+ taxRate: 0.26,
818
+ whiteListed: null
819
+ },
820
+ "Common Stock": {
821
+ assetClass: "Stock",
822
+ bucketGain: "B",
823
+ bucketLoss: "B",
824
+ taxRate: 0,
825
+ whiteListed: null
826
+ },
827
+ Equity: {
828
+ assetClass: "Stock",
829
+ bucketGain: "B",
830
+ bucketLoss: "B",
831
+ taxRate: 0,
832
+ whiteListed: null
833
+ },
834
+ "Exchange Traded Commodity": {
835
+ assetClass: "ETC",
836
+ bucketGain: "B",
837
+ bucketLoss: "B",
838
+ taxRate: 0,
839
+ whiteListed: null
840
+ },
841
+ "Corporate Bond": {
842
+ assetClass: "CorpBond",
843
+ bucketGain: "B",
844
+ bucketLoss: "B",
845
+ taxRate: 0,
846
+ whiteListed: null
847
+ },
848
+ Option: {
849
+ assetClass: "Derivative",
850
+ bucketGain: "B",
851
+ bucketLoss: "B",
852
+ taxRate: 0,
853
+ whiteListed: null
854
+ },
855
+ Future: {
856
+ assetClass: "Derivative",
857
+ bucketGain: "B",
858
+ bucketLoss: "B",
859
+ taxRate: 0,
860
+ whiteListed: null
861
+ },
862
+ Warrant: {
863
+ assetClass: "Derivative",
864
+ bucketGain: "B",
865
+ bucketLoss: "B",
866
+ taxRate: 0,
867
+ whiteListed: null
868
+ },
869
+ CFD: {
870
+ assetClass: "Derivative",
871
+ bucketGain: "B",
872
+ bucketLoss: "B",
873
+ taxRate: 0,
874
+ whiteListed: null
875
+ },
876
+ Swap: {
877
+ assetClass: "Derivative",
878
+ bucketGain: "B",
879
+ bucketLoss: "B",
880
+ taxRate: 0,
881
+ whiteListed: null
882
+ },
883
+ "Leverage Certificate": {
884
+ assetClass: "LeverageCert",
885
+ bucketGain: "B",
886
+ bucketLoss: "B",
887
+ taxRate: 0,
888
+ whiteListed: null
889
+ },
890
+ "Turbo Certificate": {
891
+ assetClass: "LeverageCert",
892
+ bucketGain: "B",
893
+ bucketLoss: "B",
894
+ taxRate: 0,
895
+ whiteListed: null
896
+ },
897
+ "Capital Protected Certificate": {
898
+ assetClass: "CapProtectedCert",
899
+ bucketGain: "A",
900
+ bucketLoss: "B",
901
+ taxRate: 0.26,
902
+ whiteListed: null
903
+ }
904
+ };
905
+ function isKnownType(t) {
906
+ return t === "Government Bond" || t in SECURITY_TYPE_MAP;
907
+ }
908
+ function classifyByType(isin, securityType, product, warnings) {
909
+ if (securityType === "Government Bond") {
910
+ const prefix = isin.slice(0, 2).toUpperCase();
911
+ const whiteListed = GOVT_BOND_WHITELIST.has(prefix);
912
+ return {
913
+ product,
914
+ assetClass: whiteListed ? "GovtBondWL" : "GovtBondOther",
915
+ bucketGain: "A",
916
+ bucketLoss: "B",
917
+ taxRate: whiteListed ? 0.125 : 0.26,
918
+ whiteListed,
919
+ confirmedByUser: false,
920
+ source: "openfigi"
921
+ };
922
+ }
923
+ const mapped = SECURITY_TYPE_MAP[securityType];
924
+ if (mapped) {
925
+ return {
926
+ product,
927
+ assetClass: mapped.assetClass,
928
+ bucketGain: mapped.bucketGain,
929
+ bucketLoss: mapped.bucketLoss,
930
+ taxRate: mapped.taxRate,
931
+ whiteListed: mapped.whiteListed,
932
+ confirmedByUser: false,
933
+ source: "openfigi"
934
+ };
935
+ }
936
+ warnings.push(
937
+ `Unrecognized type: ${securityType}. Please classify manually.`
938
+ );
939
+ return {
940
+ product,
941
+ assetClass: "Stock",
942
+ bucketGain: "B",
943
+ bucketLoss: "B",
944
+ taxRate: 0,
945
+ whiteListed: null,
946
+ confirmedByUser: false,
947
+ source: "user"
948
+ };
949
+ }
950
+ var Classifier = class {
951
+ interactive;
952
+ constructor(options) {
953
+ this.interactive = options?.interactive ?? true;
954
+ }
955
+ async load(sidecarPath) {
956
+ if (!fs3.existsSync(sidecarPath)) {
957
+ throw new ClassificationError("SIDECAR_NOT_FOUND");
958
+ }
959
+ let parsed;
960
+ try {
961
+ const raw = fs3.readFileSync(sidecarPath, "utf-8");
962
+ parsed = JSON.parse(raw);
963
+ } catch {
964
+ throw new ClassificationError("SIDECAR_MALFORMED");
965
+ }
966
+ if (typeof parsed !== "object" || parsed === null || parsed["version"] !== 1) {
967
+ throw new ClassificationError("SIDECAR_VERSION");
968
+ }
969
+ return parsed.classifications;
970
+ }
971
+ async classify(transactions, sidecarPath, _httpPost = httpsPost) {
972
+ const isinToProduct = /* @__PURE__ */ new Map();
973
+ for (const tx of transactions) {
974
+ if (tx.isin && !isinToProduct.has(tx.isin)) {
975
+ isinToProduct.set(tx.isin, tx.product);
976
+ }
977
+ }
978
+ const confirmed = {};
979
+ if (fs3.existsSync(sidecarPath)) {
980
+ const existingMap = await this.load(sidecarPath);
981
+ for (const [isin, entry] of Object.entries(existingMap)) {
982
+ if (entry.confirmedByUser) {
983
+ confirmed[isin] = entry;
984
+ }
985
+ }
986
+ }
987
+ const toProcess = [];
988
+ for (const isin of isinToProduct.keys()) {
989
+ if (!(isin in confirmed)) {
990
+ toProcess.push(isin);
991
+ }
992
+ }
993
+ const warnings = [];
994
+ const newEntries = {};
995
+ const BATCH_SIZE = 10;
996
+ for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
997
+ if (i > 0) {
998
+ await new Promise((resolve2) => setTimeout(resolve2, 6e3));
999
+ }
1000
+ const batch = toProcess.slice(i, i + BATCH_SIZE);
1001
+ const requestBody = JSON.stringify(
1002
+ batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
1003
+ );
1004
+ let response = null;
1005
+ for (let attempt = 0; attempt < 2; attempt++) {
1006
+ try {
1007
+ const r = await _httpPost(
1008
+ "https://api.openfigi.com/v3/mapping",
1009
+ requestBody,
1010
+ 1e4
1011
+ );
1012
+ if (r.status < 500) {
1013
+ response = r;
1014
+ break;
1015
+ }
1016
+ } catch {
1017
+ throw new ClassificationError("NETWORK_ERROR");
1018
+ }
1019
+ }
1020
+ if (response === null) {
1021
+ throw new ClassificationError("NETWORK_ERROR");
1022
+ }
1023
+ const results = JSON.parse(response.data);
1024
+ for (let j = 0; j < batch.length; j++) {
1025
+ const isin = batch[j];
1026
+ const result = results[j];
1027
+ const product = isinToProduct.get(isin) ?? isin;
1028
+ if (!result || result.error || !result.data || result.data.length === 0) {
1029
+ warnings.push(
1030
+ `Unrecognized type: unknown. Please classify manually.`
1031
+ );
1032
+ newEntries[isin] = {
1033
+ product,
1034
+ assetClass: "Stock",
1035
+ bucketGain: "B",
1036
+ bucketLoss: "B",
1037
+ taxRate: 0,
1038
+ whiteListed: null,
1039
+ confirmedByUser: false,
1040
+ source: "user"
1041
+ };
1042
+ continue;
1043
+ }
1044
+ const st = result.data[0]?.securityType;
1045
+ const st2 = result.data[0]?.securityType2;
1046
+ const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
1047
+ newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
1048
+ }
1049
+ }
1050
+ const mergedMap = { ...newEntries, ...confirmed };
1051
+ const sidecarContent = JSON.stringify(
1052
+ {
1053
+ version: 1,
1054
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1055
+ classifications: mergedMap,
1056
+ warnings
1057
+ },
1058
+ null,
1059
+ 2
1060
+ );
1061
+ try {
1062
+ fs3.writeFileSync(sidecarPath, sidecarContent, "utf-8");
1063
+ } catch {
1064
+ throw new ClassificationError("WRITE_ERROR");
1065
+ }
1066
+ return mergedMap;
1067
+ }
1068
+ };
1069
+
546
1070
  // src/cli/renderer.ts
547
1071
  var SEPARATOR = "\u2500".repeat(72);
548
1072
  function formatEUR(amount, locale) {
@@ -555,14 +1079,26 @@ function formatGainLoss(amount, locale) {
555
1079
  const formatted = formatEUR(Math.abs(amount), locale);
556
1080
  return amount >= 0 ? `+${formatted}` : `-${formatted}`;
557
1081
  }
1082
+ function assetClassLabel(ac, s) {
1083
+ if (ac === "ETF") return s.bucketAEtf;
1084
+ if (ac === "GovtBondWL" || ac === "GovtBondOther") return s.bucketABtpWl;
1085
+ return ac;
1086
+ }
558
1087
  function renderReport(report, s) {
559
1088
  const fmt = (n) => formatEUR(n, s.numberLocale);
560
1089
  const lines = [];
1090
+ const hasBuckets = !!(report.bucketA || report.bucketB);
561
1091
  lines.push(
562
1092
  `${s.headerMethod}: ${report.method} | ${s.headerTaxYear}: ${report.taxYear}`
563
1093
  );
564
1094
  lines.push("");
565
- const headers = [
1095
+ for (const w of report.warnings) {
1096
+ if (w.startsWith("AVVISO:") || w.startsWith("WARNING:")) {
1097
+ lines.push(w);
1098
+ lines.push("");
1099
+ }
1100
+ }
1101
+ const headerCols = [
566
1102
  s.headerIsin.padEnd(14),
567
1103
  s.headerProduct.padEnd(20),
568
1104
  s.headerQty.padStart(5),
@@ -571,10 +1107,13 @@ function renderReport(report, s) {
571
1107
  s.headerBuyEur.padStart(14),
572
1108
  s.headerSellEur.padStart(13),
573
1109
  s.headerGainLoss.padStart(17)
574
- ].join(" ");
575
- lines.push(headers);
1110
+ ];
1111
+ if (hasBuckets) {
1112
+ headerCols.push(s.headerBucket.padStart(8));
1113
+ }
1114
+ lines.push(headerCols.join(" "));
576
1115
  for (const lot of report.lots) {
577
- const row = [
1116
+ const rowCols = [
578
1117
  lot.isin.padEnd(14),
579
1118
  lot.product.substring(0, 20).padEnd(20),
580
1119
  String(lot.quantity).padStart(5),
@@ -583,8 +1122,11 @@ function renderReport(report, s) {
583
1122
  fmt(lot.buyCostEUR).padStart(14),
584
1123
  fmt(lot.sellProceedsEUR).padStart(13),
585
1124
  formatGainLoss(lot.gainLossEUR, s.numberLocale).padStart(17)
586
- ].join(" ");
587
- lines.push(row);
1125
+ ];
1126
+ if (hasBuckets) {
1127
+ rowCols.push((lot.bucket ?? "").padStart(8));
1128
+ }
1129
+ lines.push(rowCols.join(" "));
588
1130
  }
589
1131
  lines.push("");
590
1132
  lines.push(SEPARATOR);
@@ -595,15 +1137,51 @@ function renderReport(report, s) {
595
1137
  lines.push(`${s.summaryWarnings}: ${report.warnings.length}`);
596
1138
  lines.push(`${s.summaryGenerated}: ${report.generatedAt}`);
597
1139
  lines.push("");
1140
+ if (report.bucketA && report.bucketA.groups.length > 0) {
1141
+ lines.push(SEPARATOR);
1142
+ lines.push(s.bucketAHeader);
1143
+ lines.push(SEPARATOR);
1144
+ for (const g of report.bucketA.groups) {
1145
+ const label = g.assetClasses.map((ac) => assetClassLabel(ac, s)).join(", ");
1146
+ const rateDisplay = (g.taxRate * 100).toFixed(0);
1147
+ lines.push(
1148
+ `${label}: ${fmt(g.plusvalenze)} EUR \u2192 imposta: ${fmt(g.imposta)} EUR (${rateDisplay}%)`
1149
+ );
1150
+ }
1151
+ lines.push(`${s.bucketATotalTax}: ${fmt(report.bucketA.totalImposta)} EUR`);
1152
+ lines.push("");
1153
+ }
1154
+ if (report.bucketB) {
1155
+ lines.push(SEPARATOR);
1156
+ lines.push(s.bucketBHeader);
1157
+ lines.push(SEPARATOR);
1158
+ lines.push(`PLUSVALENZE: ${fmt(report.bucketB.plusvalenze)} EUR`);
1159
+ lines.push(`MINUSVALENZE: ${fmt(report.bucketB.minusvalenze)} EUR`);
1160
+ if (report.bucketB.carryForwardApplied > 0) {
1161
+ lines.push(
1162
+ `${s.bucketBCarryApplied(0)}: ${fmt(report.bucketB.carryForwardApplied)} EUR`
1163
+ );
1164
+ }
1165
+ let resultLine = `${s.bucketBResult}: ${fmt(report.bucketB.netResult)} EUR`;
1166
+ if (report.bucketB.carryForwardRemaining > 0) {
1167
+ resultLine += ` ${s.bucketBCarryNote}`;
1168
+ }
1169
+ lines.push(resultLine);
1170
+ lines.push("");
1171
+ }
1172
+ if (hasBuckets) {
1173
+ lines.push(s.warnMixedBuckets);
1174
+ lines.push("");
1175
+ }
598
1176
  lines.push(s.disclaimer);
599
1177
  return lines.join("\n");
600
1178
  }
601
1179
 
602
1180
  // src/cli/commands/rates.ts
603
- import * as fs3 from "fs";
1181
+ import * as fs4 from "fs";
604
1182
  import * as path3 from "path";
605
1183
  import * as os2 from "os";
606
- import * as https from "https";
1184
+ import * as https2 from "https";
607
1185
  function getSnapshotPath() {
608
1186
  const platform = process.platform;
609
1187
  let configDir;
@@ -628,9 +1206,9 @@ function getCoverage(snapshot) {
628
1206
  return { start, end, currencies: currencies.sort().join(", ") };
629
1207
  }
630
1208
  async function fetchEcbData(currency) {
631
- return new Promise((resolve, reject) => {
1209
+ return new Promise((resolve2, reject) => {
632
1210
  const url = `https://data-api.ecb.europa.eu/service/data/EXR/D.${currency}.EUR.SP00.A?format=csvdata&startPeriod=2019-01-01`;
633
- https.get(url, (res) => {
1211
+ https2.get(url, (res) => {
634
1212
  let data = "";
635
1213
  res.on("data", (chunk) => data += chunk);
636
1214
  res.on("end", () => {
@@ -645,7 +1223,7 @@ async function fetchEcbData(currency) {
645
1223
  rates[date] = rate;
646
1224
  }
647
1225
  }
648
- resolve(rates);
1226
+ resolve2(rates);
649
1227
  });
650
1228
  res.on("error", reject);
651
1229
  }).on("error", reject);
@@ -656,7 +1234,7 @@ async function updateRates(snapshotPath, stdout, stderr, s) {
656
1234
  let existing = {};
657
1235
  try {
658
1236
  existing = JSON.parse(
659
- fs3.readFileSync(snapshotPath, "utf8")
1237
+ fs4.readFileSync(snapshotPath, "utf8")
660
1238
  );
661
1239
  } catch {
662
1240
  }
@@ -677,8 +1255,8 @@ async function updateRates(snapshotPath, stdout, stderr, s) {
677
1255
  `);
678
1256
  }
679
1257
  }
680
- fs3.mkdirSync(path3.dirname(snapshotPath), { recursive: true });
681
- fs3.writeFileSync(
1258
+ fs4.mkdirSync(path3.dirname(snapshotPath), { recursive: true });
1259
+ fs4.writeFileSync(
682
1260
  snapshotPath,
683
1261
  JSON.stringify(existing, null, 2) + "\n",
684
1262
  "utf8"
@@ -713,7 +1291,7 @@ async function runCalc(positional, flags, s, stdout, stderr) {
713
1291
  }
714
1292
  let csv;
715
1293
  try {
716
- csv = fs4.readFileSync(filePath, "utf8");
1294
+ csv = fs5.readFileSync(filePath, "utf8");
717
1295
  } catch {
718
1296
  stderr.write(`Cannot read file: ${filePath}
719
1297
  `);
@@ -742,6 +1320,51 @@ async function runCalc(positional, flags, s, stdout, stderr) {
742
1320
  stderr.write("--method must be LIFO or FIFO\n");
743
1321
  return 2;
744
1322
  }
1323
+ const sidecarPath = filePath.replace(/\.csv$/i, "") + ".classify.json";
1324
+ let classification;
1325
+ if (fs5.existsSync(sidecarPath)) {
1326
+ try {
1327
+ const classifier = new Classifier({ interactive: false });
1328
+ classification = await classifier.load(sidecarPath);
1329
+ } catch {
1330
+ stderr.write(`Cannot load sidecar: ${sidecarPath}
1331
+ `);
1332
+ return 1;
1333
+ }
1334
+ }
1335
+ const rawCf = flags["carry-forward"];
1336
+ const rawCarryForwards = Array.isArray(rawCf) ? rawCf : rawCf ? [rawCf] : [];
1337
+ const cfRegex = /^\d{4}:\d+(\.\d+)?$/;
1338
+ for (const cf of rawCarryForwards) {
1339
+ if (!cfRegex.test(cf)) {
1340
+ stderr.write(s.carryForwardInvalidFormat + "\n");
1341
+ return 2;
1342
+ }
1343
+ }
1344
+ const xdgConfig = process.env["XDG_CONFIG_HOME"] ?? path4.join(os3.homedir(), ".config");
1345
+ const cfConfigPath = path4.join(
1346
+ xdgConfig,
1347
+ "minus-tracker",
1348
+ "carryforward.json"
1349
+ );
1350
+ const cfFromFile = {};
1351
+ if (fs5.existsSync(cfConfigPath)) {
1352
+ try {
1353
+ const raw = JSON.parse(fs5.readFileSync(cfConfigPath, "utf-8"));
1354
+ for (const entry of raw.losses ?? []) {
1355
+ cfFromFile[entry.year] = entry.amount;
1356
+ }
1357
+ } catch {
1358
+ }
1359
+ }
1360
+ const cfMerged = { ...cfFromFile };
1361
+ for (const cf of rawCarryForwards) {
1362
+ const [yearStr, amountStr] = cf.split(":");
1363
+ cfMerged[parseInt(yearStr, 10)] = parseFloat(amountStr);
1364
+ }
1365
+ const carryForward = Object.entries(cfMerged).map(
1366
+ ([year, amount]) => ({ year: parseInt(year, 10), amount })
1367
+ );
745
1368
  const parser = new DEGIROParser();
746
1369
  let transactions;
747
1370
  try {
@@ -757,7 +1380,10 @@ async function runCalc(positional, flags, s, stdout, stderr) {
757
1380
  }
758
1381
  throw err;
759
1382
  }
760
- const calculator = new Calculator(transactions, parser.warnings);
1383
+ const calculator = new Calculator(transactions, parser.warnings, {
1384
+ classification,
1385
+ carryForward: carryForward.length > 0 ? carryForward : void 0
1386
+ });
761
1387
  const report = calculator.calculateGains(method);
762
1388
  if (flags["json"]) {
763
1389
  stdout.write(JSON.stringify(report, null, 2) + "\n");
@@ -768,7 +1394,7 @@ async function runCalc(positional, flags, s, stdout, stderr) {
768
1394
  }
769
1395
 
770
1396
  // src/cli/commands/validate.ts
771
- import * as fs5 from "fs";
1397
+ import * as fs6 from "fs";
772
1398
  async function runValidate(positional, flags, s, stdout, stderr) {
773
1399
  const filePath = positional[0];
774
1400
  if (!filePath) {
@@ -777,7 +1403,7 @@ async function runValidate(positional, flags, s, stdout, stderr) {
777
1403
  }
778
1404
  let csv;
779
1405
  try {
780
- csv = fs5.readFileSync(filePath, "utf8");
1406
+ csv = fs6.readFileSync(filePath, "utf8");
781
1407
  } catch {
782
1408
  stderr.write(`Cannot read file: ${filePath}
783
1409
  `);
@@ -843,6 +1469,523 @@ async function runConfig(positional, flags, s, stdout, stderr) {
843
1469
  return 2;
844
1470
  }
845
1471
 
1472
+ // src/cli/commands/stress-test.ts
1473
+ import * as fs7 from "fs";
1474
+ import * as path5 from "path";
1475
+ import * as os4 from "os";
1476
+ import { fileURLToPath as fileURLToPath2 } from "url";
1477
+ import { spawnSync as spawnSync2 } from "child_process";
1478
+
1479
+ // src/stress/generator.ts
1480
+ function formatDate(isoDate) {
1481
+ const [yyyy, mm, dd] = isoDate.split("-");
1482
+ return `${dd}-${mm}-${yyyy}`;
1483
+ }
1484
+ function generateCsv(scenario) {
1485
+ if (scenario.special === "invalid_csv") {
1486
+ return "NOT_A_CSV\0\ngarbage data";
1487
+ }
1488
+ const allColumns = [
1489
+ "Date",
1490
+ "Time",
1491
+ "Product",
1492
+ "ISIN",
1493
+ "Exchange",
1494
+ "Execution centre",
1495
+ "Quantity",
1496
+ "Price",
1497
+ "Local value",
1498
+ "Local value currency",
1499
+ "Value",
1500
+ "Value currency",
1501
+ "Exchange rate",
1502
+ "Transaction costs",
1503
+ "Transaction costs currency",
1504
+ "Total",
1505
+ "Total currency",
1506
+ "Order ID"
1507
+ ];
1508
+ let columns = allColumns;
1509
+ if (scenario.special === "missing_col_isin") {
1510
+ columns = columns.filter((c) => c !== "ISIN");
1511
+ } else if (scenario.special === "missing_col_quantity") {
1512
+ columns = columns.filter((c) => c !== "Quantity");
1513
+ } else if (scenario.special === "missing_col_price") {
1514
+ columns = columns.filter((c) => c !== "Price");
1515
+ }
1516
+ const buildRow = (values, includeColumns) => {
1517
+ return includeColumns.map((col) => values[col] ?? "").join(",");
1518
+ };
1519
+ const header = columns.join(",");
1520
+ const rows = [header];
1521
+ for (let rowIndex = 0; rowIndex < scenario.transactions.length; rowIndex++) {
1522
+ const tx = scenario.transactions[rowIndex];
1523
+ const localValue = -tx.qty * tx.price;
1524
+ const total = localValue - tx.fee;
1525
+ const rowValues = {
1526
+ Date: formatDate(tx.date),
1527
+ Time: "09:00",
1528
+ Product: tx.product,
1529
+ ISIN: tx.isin,
1530
+ Exchange: "XEUR",
1531
+ "Execution centre": "XEUR",
1532
+ Quantity: String(tx.qty),
1533
+ Price: tx.price.toFixed(2),
1534
+ "Local value": localValue.toFixed(2),
1535
+ "Local value currency": tx.currency,
1536
+ Value: localValue.toFixed(2),
1537
+ "Value currency": "EUR",
1538
+ "Exchange rate": "1",
1539
+ "Transaction costs": (-Math.abs(tx.fee)).toFixed(2),
1540
+ "Transaction costs currency": "EUR",
1541
+ Total: total.toFixed(2),
1542
+ "Total currency": "EUR",
1543
+ "Order ID": `stress-${scenario.id}-${rowIndex}`
1544
+ };
1545
+ const row = buildRow(rowValues, columns);
1546
+ rows.push(row);
1547
+ }
1548
+ return rows.join("\n");
1549
+ }
1550
+
1551
+ // src/stress/runner.ts
1552
+ import { spawnSync } from "child_process";
1553
+ import { writeFileSync as writeFileSync4 } from "fs";
1554
+ import { join as join5 } from "path";
1555
+ function countWarnings(text) {
1556
+ if (!text) return 0;
1557
+ const match = text.match(/(?:warnings|avvertenze)\s*:\s*(\d+)/i);
1558
+ return match ? parseInt(match[1], 10) : 0;
1559
+ }
1560
+ function isValidJson(jsonStr) {
1561
+ try {
1562
+ const obj = JSON.parse(jsonStr);
1563
+ return typeof obj === "object" && obj !== null && "plusvalenze" in obj && "minusvalenze" in obj && "lots" in obj;
1564
+ } catch {
1565
+ return false;
1566
+ }
1567
+ }
1568
+ function runCommand(cliBin, args, cmdLabel, expectedExitCode, outputShapeCheck) {
1569
+ const isJsFile = cliBin.endsWith(".js");
1570
+ let result;
1571
+ if (isJsFile) {
1572
+ result = spawnSync("node", [cliBin, ...args], {
1573
+ encoding: "utf8",
1574
+ timeout: 15e3,
1575
+ env: { ...process.env, MINUS_TRACKER_LANG: void 0 }
1576
+ });
1577
+ } else {
1578
+ result = spawnSync(cliBin, args, {
1579
+ encoding: "utf8",
1580
+ timeout: 15e3,
1581
+ env: { ...process.env, MINUS_TRACKER_LANG: void 0 }
1582
+ });
1583
+ }
1584
+ const exitCode = result.status ?? 1;
1585
+ const stdout = result.stdout || "";
1586
+ const stderr = result.stderr || "";
1587
+ const exitCodeMatch = exitCode === expectedExitCode;
1588
+ const shapeCheck = outputShapeCheck(stdout, stderr, exitCode);
1589
+ let pass = exitCodeMatch && shapeCheck.valid;
1590
+ let failure;
1591
+ if (!exitCodeMatch) {
1592
+ failure = `exit code mismatch: expected ${expectedExitCode}, got ${exitCode}`;
1593
+ } else if (!shapeCheck.valid) {
1594
+ failure = shapeCheck.reason;
1595
+ }
1596
+ return {
1597
+ cmd: cmdLabel,
1598
+ exitCode,
1599
+ stdout,
1600
+ stderr,
1601
+ pass,
1602
+ failure
1603
+ };
1604
+ }
1605
+ function runScenario(scenario, csvContent, outputDir, cliBin) {
1606
+ const csvFileName = `${scenario.id}-${scenario.slug}.csv`;
1607
+ const csvFile = join5(outputDir, csvFileName);
1608
+ writeFileSync4(csvFile, csvContent, "utf8");
1609
+ const results = [];
1610
+ results.push(
1611
+ runCommand(
1612
+ cliBin,
1613
+ ["calc", csvFile],
1614
+ "calc",
1615
+ scenario.expect.calc_exit,
1616
+ (stdout) => {
1617
+ if (scenario.expect.calc_exit === 0) {
1618
+ const hasPlus = /plusvalenze|plusvalues/i.test(stdout);
1619
+ return hasPlus ? { valid: true } : {
1620
+ valid: false,
1621
+ reason: "calc output missing plusvalenze/plusvalues (exit 0)"
1622
+ };
1623
+ }
1624
+ return { valid: true };
1625
+ }
1626
+ )
1627
+ );
1628
+ results.push(
1629
+ runCommand(
1630
+ cliBin,
1631
+ ["calc", "--method", "FIFO", csvFile],
1632
+ "calc --method FIFO",
1633
+ scenario.expect.fifo_exit,
1634
+ (stdout) => {
1635
+ if (scenario.expect.fifo_exit === 0) {
1636
+ const hasPlus = /plusvalenze|plusvalues/i.test(stdout);
1637
+ return hasPlus ? { valid: true } : {
1638
+ valid: false,
1639
+ reason: "calc --method FIFO output missing plusvalenze/plusvalues (exit 0)"
1640
+ };
1641
+ }
1642
+ return { valid: true };
1643
+ }
1644
+ )
1645
+ );
1646
+ results.push(
1647
+ runCommand(
1648
+ cliBin,
1649
+ ["calc", "--json", csvFile],
1650
+ "calc --json",
1651
+ scenario.expect.json_exit,
1652
+ (stdout) => {
1653
+ if (scenario.expect.json_exit === 0) {
1654
+ const isValid = isValidJson(stdout);
1655
+ return isValid ? { valid: true } : {
1656
+ valid: false,
1657
+ reason: "calc --json output is not valid JSON with required keys (exit 0)"
1658
+ };
1659
+ }
1660
+ return { valid: true };
1661
+ }
1662
+ )
1663
+ );
1664
+ results.push(
1665
+ runCommand(
1666
+ cliBin,
1667
+ ["calc", "--lang", "en", csvFile],
1668
+ "calc --lang en",
1669
+ scenario.expect.en_exit,
1670
+ (stdout) => {
1671
+ if (scenario.expect.en_exit === 0) {
1672
+ const nonEmpty = stdout.trim().length > 0;
1673
+ return nonEmpty ? { valid: true } : {
1674
+ valid: false,
1675
+ reason: "calc --lang en output is empty (exit 0)"
1676
+ };
1677
+ }
1678
+ return { valid: true };
1679
+ }
1680
+ )
1681
+ );
1682
+ results.push(
1683
+ runCommand(
1684
+ cliBin,
1685
+ ["validate", csvFile],
1686
+ "validate",
1687
+ scenario.expect.validate_exit,
1688
+ (stdout) => {
1689
+ if (scenario.expect.validate_exit !== 0) return { valid: true };
1690
+ const nonEmpty = stdout.trim().length > 0;
1691
+ return nonEmpty ? { valid: true } : {
1692
+ valid: false,
1693
+ reason: "validate output is empty"
1694
+ };
1695
+ }
1696
+ )
1697
+ );
1698
+ const enCalcResult = results[3];
1699
+ const actualWarningCount = countWarnings(enCalcResult.stdout);
1700
+ const warningCheckPass = actualWarningCount >= scenario.expect.warning_count;
1701
+ const pass = results.every((r) => r.pass) && warningCheckPass;
1702
+ return {
1703
+ id: scenario.id,
1704
+ category: scenario.category,
1705
+ slug: scenario.slug,
1706
+ description: scenario.description,
1707
+ csvFile,
1708
+ results,
1709
+ pass,
1710
+ warningCheckPass,
1711
+ expectedWarningCount: scenario.expect.warning_count,
1712
+ actualWarningCount
1713
+ };
1714
+ }
1715
+
1716
+ // src/stress/reporter.ts
1717
+ function buildReport(results, rangeStart, rangeEnd) {
1718
+ const passed = results.filter((r) => r.pass).length;
1719
+ const failed = results.length - passed;
1720
+ return {
1721
+ totalScenarios: results.length,
1722
+ passed,
1723
+ failed,
1724
+ results,
1725
+ rangeStart,
1726
+ rangeEnd
1727
+ };
1728
+ }
1729
+ function formatJson(report) {
1730
+ return JSON.stringify(report, null, 2);
1731
+ }
1732
+ function formatTable(report) {
1733
+ const lines = [];
1734
+ lines.push("STRESS TEST \u2014 minus-tracker");
1735
+ lines.push(
1736
+ `Scenari: ${report.totalScenarios} | Passati: ${report.passed} | Falliti: ${report.failed}`
1737
+ );
1738
+ lines.push("");
1739
+ const idCol = "ID";
1740
+ const categoryCol = "CATEGORIA";
1741
+ const descCol = "DESCRIZIONE";
1742
+ const resultCol = "ESITO";
1743
+ lines.push(formatRow(idCol, categoryCol, descCol, resultCol));
1744
+ for (const result of report.results) {
1745
+ const idStr = result.id.padStart(3, "0");
1746
+ const status = result.pass ? "\u2713 PASS" : "\u2717 FAIL";
1747
+ lines.push(formatRow(idStr, result.category, result.description, status));
1748
+ if (!result.pass) {
1749
+ for (const cmdResult of result.results) {
1750
+ if (!cmdResult.pass) {
1751
+ if (cmdResult.failure) {
1752
+ let failureText = "";
1753
+ if (cmdResult.failure.includes("exit code mismatch")) {
1754
+ const match = cmdResult.failure.match(
1755
+ /expected (\d+), got (\d+)/
1756
+ );
1757
+ if (match) {
1758
+ failureText = `atteso exit ${match[1]}, ottenuto exit ${match[2]}`;
1759
+ }
1760
+ } else {
1761
+ failureText = `output shape non valido \u2014 ${cmdResult.failure}`;
1762
+ }
1763
+ lines.push(` \u2192 ${cmdResult.cmd}: ${failureText}`);
1764
+ }
1765
+ }
1766
+ }
1767
+ if (!result.warningCheckPass) {
1768
+ lines.push(
1769
+ ` \u2192 avvertenze: attese \u2265${result.expectedWarningCount}, trovate ${result.actualWarningCount}`
1770
+ );
1771
+ }
1772
+ }
1773
+ }
1774
+ lines.push("");
1775
+ lines.push(`PASSATI: ${report.passed}`);
1776
+ lines.push(`FALLITI: ${report.failed}`);
1777
+ lines.push("");
1778
+ return lines.join("\n");
1779
+ }
1780
+ function formatRow(id, category, description, esito) {
1781
+ const idWidth = 4;
1782
+ const categoryWidth = 22;
1783
+ const descWidth = 40;
1784
+ const esitoWidth = 8;
1785
+ const idCol = id.padEnd(idWidth);
1786
+ const categoryCol = truncateOrPad(category, categoryWidth);
1787
+ const descCol = truncateOrPad(description, descWidth);
1788
+ const esitoCol = esito.padEnd(esitoWidth);
1789
+ return `${idCol} ${categoryCol} ${descCol} ${esitoCol}`;
1790
+ }
1791
+ function truncateOrPad(str, maxWidth) {
1792
+ if (str.length > maxWidth) {
1793
+ return str.slice(0, maxWidth - 2) + "..";
1794
+ }
1795
+ return str.padEnd(maxWidth);
1796
+ }
1797
+
1798
+ // src/cli/commands/stress-test.ts
1799
+ async function runStressTest(_positionals, flags, stdout, stderr) {
1800
+ const rangeStr = flags["range"] ?? "1-100";
1801
+ const rangeParts = rangeStr.split("-");
1802
+ if (rangeParts.length !== 2) {
1803
+ stderr.write(
1804
+ `Error: --range must be in the form N-M (e.g. 1-100), got: "${rangeStr}"
1805
+ `
1806
+ );
1807
+ return 2;
1808
+ }
1809
+ const rangeStart = parseInt(rangeParts[0], 10);
1810
+ const rangeEnd = parseInt(rangeParts[1], 10);
1811
+ if (isNaN(rangeStart) || isNaN(rangeEnd) || rangeStart <= 0 || rangeEnd <= 0 || rangeStart > rangeEnd || rangeStart > 100 || rangeEnd > 100) {
1812
+ stderr.write(
1813
+ `Error: --range values must be positive integers between 1 and 100 with start \u2264 end, got: "${rangeStr}"
1814
+ `
1815
+ );
1816
+ return 2;
1817
+ }
1818
+ const __dirname = path5.dirname(fileURLToPath2(import.meta.url));
1819
+ const manifestPath = path5.join(__dirname, "../data/stress-manifest.json");
1820
+ let manifest;
1821
+ try {
1822
+ const raw = fs7.readFileSync(manifestPath, "utf8");
1823
+ manifest = JSON.parse(raw);
1824
+ } catch {
1825
+ stderr.write(`Error: could not load stress manifest at ${manifestPath}
1826
+ `);
1827
+ return 1;
1828
+ }
1829
+ const cliBin = process.argv[1];
1830
+ let outputDir;
1831
+ const outputDirFlag = flags["output-dir"];
1832
+ if (outputDirFlag) {
1833
+ outputDir = outputDirFlag;
1834
+ fs7.mkdirSync(outputDir, { recursive: true });
1835
+ } else {
1836
+ outputDir = fs7.mkdtempSync(path5.join(os4.tmpdir(), "minus-tracker-stress-"));
1837
+ }
1838
+ const ratesCheck = spawnSync2("node", [cliBin, "rates", "--check"], {
1839
+ encoding: "utf8",
1840
+ timeout: 15e3
1841
+ });
1842
+ if ((ratesCheck.status ?? 1) !== 0) {
1843
+ stderr.write("Warning: rates --check failed; ECB rates may be stale.\n");
1844
+ }
1845
+ const configShow = spawnSync2("node", [cliBin, "config", "--show"], {
1846
+ encoding: "utf8",
1847
+ timeout: 15e3
1848
+ });
1849
+ if ((configShow.status ?? 1) !== 0) {
1850
+ stderr.write("Warning: config --show failed.\n");
1851
+ }
1852
+ const scenarios = manifest.scenarios.filter((s) => {
1853
+ const id = parseInt(s.id, 10);
1854
+ return id >= rangeStart && id <= rangeEnd;
1855
+ });
1856
+ const results = [];
1857
+ for (const scenario of scenarios) {
1858
+ const csv = generateCsv(scenario);
1859
+ const result = runScenario(scenario, csv, outputDir, cliBin);
1860
+ results.push(result);
1861
+ stdout.write(
1862
+ ` [${result.pass ? "\u2713" : "\u2717"}] ${scenario.id} ${scenario.slug}
1863
+ `
1864
+ );
1865
+ }
1866
+ const report = buildReport(results, rangeStart, rangeEnd);
1867
+ if (flags["json"]) {
1868
+ stdout.write(formatJson(report) + "\n");
1869
+ } else {
1870
+ stdout.write(formatTable(report) + "\n");
1871
+ }
1872
+ if (!flags["keep"]) {
1873
+ fs7.rmSync(outputDir, { recursive: true, force: true });
1874
+ }
1875
+ return report.failed === 0 ? 0 : 1;
1876
+ }
1877
+
1878
+ // src/cli/commands/classify.ts
1879
+ import * as fs8 from "fs";
1880
+ import * as path6 from "path";
1881
+ async function runClassify(positional, flags, s, stdout, stderr) {
1882
+ const offline = Boolean(flags["offline"]);
1883
+ if (!process.stdin.isTTY && !offline) {
1884
+ stderr.write(s.classifyNonTtyError + "\n");
1885
+ return 2;
1886
+ }
1887
+ const csvPath = positional[0];
1888
+ if (!csvPath) {
1889
+ stderr.write("Usage: minus-tracker classify [--offline] <file.csv>\n");
1890
+ return 2;
1891
+ }
1892
+ let csv;
1893
+ try {
1894
+ csv = fs8.readFileSync(csvPath, "utf8");
1895
+ } catch {
1896
+ stderr.write(`Cannot read file: ${csvPath}
1897
+ `);
1898
+ return 1;
1899
+ }
1900
+ const parser = new DEGIROParser();
1901
+ let transactions;
1902
+ try {
1903
+ transactions = parser.parse(csv);
1904
+ } catch (err) {
1905
+ if (err instanceof ParseError) {
1906
+ if (err.code === "INVALID_CSV") {
1907
+ stderr.write(s.errorInvalidCsv + "\n");
1908
+ } else {
1909
+ stderr.write(s.errorMissingColumn(err.columnName) + "\n");
1910
+ }
1911
+ return 1;
1912
+ }
1913
+ throw err;
1914
+ }
1915
+ const base = csvPath.replace(/\.csv$/i, "");
1916
+ const sidecarPath = base + ".classify.json";
1917
+ if (offline) {
1918
+ stdout.write(s.classifyOfflineWarning + "\n");
1919
+ const isinToProduct = /* @__PURE__ */ new Map();
1920
+ for (const tx of transactions) {
1921
+ if (tx.isin && !isinToProduct.has(tx.isin)) {
1922
+ isinToProduct.set(tx.isin, tx.product);
1923
+ }
1924
+ }
1925
+ const classifications = {};
1926
+ for (const [isin, product] of isinToProduct.entries()) {
1927
+ classifications[isin] = {
1928
+ product,
1929
+ assetClass: "ETF",
1930
+ bucketGain: "A",
1931
+ bucketLoss: "B",
1932
+ taxRate: 0.26,
1933
+ whiteListed: null,
1934
+ confirmedByUser: true,
1935
+ source: "user"
1936
+ };
1937
+ }
1938
+ const sidecarContent = JSON.stringify(
1939
+ {
1940
+ version: 1,
1941
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1942
+ classifications,
1943
+ warnings: []
1944
+ },
1945
+ null,
1946
+ 2
1947
+ );
1948
+ try {
1949
+ fs8.writeFileSync(sidecarPath, sidecarContent, "utf-8");
1950
+ } catch {
1951
+ stderr.write(`Cannot write sidecar: ${sidecarPath}
1952
+ `);
1953
+ return 1;
1954
+ }
1955
+ stdout.write(s.classifyWritten(sidecarPath) + "\n");
1956
+ return 0;
1957
+ }
1958
+ const classifier = new Classifier({ interactive: true });
1959
+ if (fs8.existsSync(sidecarPath)) {
1960
+ const existingMap = await classifier.load(sidecarPath);
1961
+ const confirmedCount = Object.values(existingMap).filter(
1962
+ (e) => e.confirmedByUser
1963
+ ).length;
1964
+ stdout.write(s.classifyMergePrompt(confirmedCount));
1965
+ const answer = await readLine(process.stdin);
1966
+ if (answer.trim().toLowerCase() === "n") {
1967
+ return 0;
1968
+ }
1969
+ }
1970
+ await classifier.classify(transactions, sidecarPath);
1971
+ stdout.write(s.classifyWritten(path6.resolve(sidecarPath)) + "\n");
1972
+ return 0;
1973
+ }
1974
+ function readLine(stream) {
1975
+ return new Promise((resolve2) => {
1976
+ let buf = "";
1977
+ const onData = (chunk) => {
1978
+ buf += chunk.toString();
1979
+ const nl = buf.indexOf("\n");
1980
+ if (nl !== -1) {
1981
+ stream.removeListener("data", onData);
1982
+ resolve2(buf.slice(0, nl));
1983
+ }
1984
+ };
1985
+ stream.on("data", onData);
1986
+ });
1987
+ }
1988
+
846
1989
  // src/cli/index.ts
847
1990
  async function main() {
848
1991
  const { values, positionals } = parseArgs({
@@ -854,7 +1997,11 @@ async function main() {
854
1997
  json: { type: "boolean", default: false },
855
1998
  check: { type: "boolean", default: false },
856
1999
  update: { type: "boolean", default: false },
857
- show: { type: "boolean", default: false }
2000
+ show: { type: "boolean", default: false },
2001
+ range: { type: "string" },
2002
+ keep: { type: "boolean", default: false },
2003
+ "output-dir": { type: "string" },
2004
+ offline: { type: "boolean", default: false }
858
2005
  },
859
2006
  allowPositionals: true,
860
2007
  strict: false
@@ -881,14 +2028,23 @@ async function main() {
881
2028
  case "config":
882
2029
  exitCode = await runConfig(restPositionals, flags, s, stdout, stderr);
883
2030
  break;
2031
+ case "stress-test":
2032
+ exitCode = await runStressTest(restPositionals, flags, stdout, stderr);
2033
+ break;
2034
+ case "classify":
2035
+ exitCode = await runClassify(restPositionals, flags, s, stdout, stderr);
2036
+ break;
884
2037
  default:
885
2038
  stderr.write(
886
- "Usage: minus-tracker <calc|validate|rates|config> [options] [file]\n"
2039
+ "Usage: minus-tracker <calc|validate|rates|config|stress-test|classify> [options] [file]\n"
887
2040
  );
888
2041
  exitCode = 2;
889
2042
  }
890
2043
  } catch (err) {
891
- if (err instanceof ParseError) {
2044
+ if (err instanceof ClassificationError) {
2045
+ stderr.write(err.message + "\n");
2046
+ exitCode = 1;
2047
+ } else if (err instanceof ParseError) {
892
2048
  if (err.code === "INVALID_CSV") {
893
2049
  stderr.write(s.errorInvalidCsv + "\n");
894
2050
  } else {