@gabrielerandelli/minus-tracker 0.5.8 → 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: (path5) => `Snapshot scritto in ${path5}`,
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: (path5) => `Snapshot written to ${path5}`,
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";
@@ -453,13 +511,11 @@ function inferTaxYear(transactions) {
453
511
  var Calculator = class {
454
512
  _transactions;
455
513
  _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) {
514
+ _options;
515
+ constructor(transactions, parseWarnings, options) {
461
516
  this._transactions = transactions;
462
517
  this._parseWarnings = parseWarnings ?? [];
518
+ this._options = options ?? {};
463
519
  }
464
520
  /**
465
521
  * Run LIFO or FIFO lot-matching over the transaction list.
@@ -553,6 +609,104 @@ var Calculator = class {
553
609
  if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
554
610
  else minusvalenze += Math.abs(lot.gainLossEUR);
555
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
+ }
556
710
  return {
557
711
  method,
558
712
  taxYear,
@@ -567,6 +721,352 @@ var Calculator = class {
567
721
  }
568
722
  };
569
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
+
570
1070
  // src/cli/renderer.ts
571
1071
  var SEPARATOR = "\u2500".repeat(72);
572
1072
  function formatEUR(amount, locale) {
@@ -579,14 +1079,26 @@ function formatGainLoss(amount, locale) {
579
1079
  const formatted = formatEUR(Math.abs(amount), locale);
580
1080
  return amount >= 0 ? `+${formatted}` : `-${formatted}`;
581
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
+ }
582
1087
  function renderReport(report, s) {
583
1088
  const fmt = (n) => formatEUR(n, s.numberLocale);
584
1089
  const lines = [];
1090
+ const hasBuckets = !!(report.bucketA || report.bucketB);
585
1091
  lines.push(
586
1092
  `${s.headerMethod}: ${report.method} | ${s.headerTaxYear}: ${report.taxYear}`
587
1093
  );
588
1094
  lines.push("");
589
- 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 = [
590
1102
  s.headerIsin.padEnd(14),
591
1103
  s.headerProduct.padEnd(20),
592
1104
  s.headerQty.padStart(5),
@@ -595,10 +1107,13 @@ function renderReport(report, s) {
595
1107
  s.headerBuyEur.padStart(14),
596
1108
  s.headerSellEur.padStart(13),
597
1109
  s.headerGainLoss.padStart(17)
598
- ].join(" ");
599
- lines.push(headers);
1110
+ ];
1111
+ if (hasBuckets) {
1112
+ headerCols.push(s.headerBucket.padStart(8));
1113
+ }
1114
+ lines.push(headerCols.join(" "));
600
1115
  for (const lot of report.lots) {
601
- const row = [
1116
+ const rowCols = [
602
1117
  lot.isin.padEnd(14),
603
1118
  lot.product.substring(0, 20).padEnd(20),
604
1119
  String(lot.quantity).padStart(5),
@@ -607,8 +1122,11 @@ function renderReport(report, s) {
607
1122
  fmt(lot.buyCostEUR).padStart(14),
608
1123
  fmt(lot.sellProceedsEUR).padStart(13),
609
1124
  formatGainLoss(lot.gainLossEUR, s.numberLocale).padStart(17)
610
- ].join(" ");
611
- lines.push(row);
1125
+ ];
1126
+ if (hasBuckets) {
1127
+ rowCols.push((lot.bucket ?? "").padStart(8));
1128
+ }
1129
+ lines.push(rowCols.join(" "));
612
1130
  }
613
1131
  lines.push("");
614
1132
  lines.push(SEPARATOR);
@@ -619,15 +1137,51 @@ function renderReport(report, s) {
619
1137
  lines.push(`${s.summaryWarnings}: ${report.warnings.length}`);
620
1138
  lines.push(`${s.summaryGenerated}: ${report.generatedAt}`);
621
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
+ }
622
1176
  lines.push(s.disclaimer);
623
1177
  return lines.join("\n");
624
1178
  }
625
1179
 
626
1180
  // src/cli/commands/rates.ts
627
- import * as fs3 from "fs";
1181
+ import * as fs4 from "fs";
628
1182
  import * as path3 from "path";
629
1183
  import * as os2 from "os";
630
- import * as https from "https";
1184
+ import * as https2 from "https";
631
1185
  function getSnapshotPath() {
632
1186
  const platform = process.platform;
633
1187
  let configDir;
@@ -652,9 +1206,9 @@ function getCoverage(snapshot) {
652
1206
  return { start, end, currencies: currencies.sort().join(", ") };
653
1207
  }
654
1208
  async function fetchEcbData(currency) {
655
- return new Promise((resolve, reject) => {
1209
+ return new Promise((resolve2, reject) => {
656
1210
  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) => {
1211
+ https2.get(url, (res) => {
658
1212
  let data = "";
659
1213
  res.on("data", (chunk) => data += chunk);
660
1214
  res.on("end", () => {
@@ -669,7 +1223,7 @@ async function fetchEcbData(currency) {
669
1223
  rates[date] = rate;
670
1224
  }
671
1225
  }
672
- resolve(rates);
1226
+ resolve2(rates);
673
1227
  });
674
1228
  res.on("error", reject);
675
1229
  }).on("error", reject);
@@ -680,7 +1234,7 @@ async function updateRates(snapshotPath, stdout, stderr, s) {
680
1234
  let existing = {};
681
1235
  try {
682
1236
  existing = JSON.parse(
683
- fs3.readFileSync(snapshotPath, "utf8")
1237
+ fs4.readFileSync(snapshotPath, "utf8")
684
1238
  );
685
1239
  } catch {
686
1240
  }
@@ -701,8 +1255,8 @@ async function updateRates(snapshotPath, stdout, stderr, s) {
701
1255
  `);
702
1256
  }
703
1257
  }
704
- fs3.mkdirSync(path3.dirname(snapshotPath), { recursive: true });
705
- fs3.writeFileSync(
1258
+ fs4.mkdirSync(path3.dirname(snapshotPath), { recursive: true });
1259
+ fs4.writeFileSync(
706
1260
  snapshotPath,
707
1261
  JSON.stringify(existing, null, 2) + "\n",
708
1262
  "utf8"
@@ -737,7 +1291,7 @@ async function runCalc(positional, flags, s, stdout, stderr) {
737
1291
  }
738
1292
  let csv;
739
1293
  try {
740
- csv = fs4.readFileSync(filePath, "utf8");
1294
+ csv = fs5.readFileSync(filePath, "utf8");
741
1295
  } catch {
742
1296
  stderr.write(`Cannot read file: ${filePath}
743
1297
  `);
@@ -766,6 +1320,51 @@ async function runCalc(positional, flags, s, stdout, stderr) {
766
1320
  stderr.write("--method must be LIFO or FIFO\n");
767
1321
  return 2;
768
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
+ );
769
1368
  const parser = new DEGIROParser();
770
1369
  let transactions;
771
1370
  try {
@@ -781,7 +1380,10 @@ async function runCalc(positional, flags, s, stdout, stderr) {
781
1380
  }
782
1381
  throw err;
783
1382
  }
784
- 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
+ });
785
1387
  const report = calculator.calculateGains(method);
786
1388
  if (flags["json"]) {
787
1389
  stdout.write(JSON.stringify(report, null, 2) + "\n");
@@ -792,7 +1394,7 @@ async function runCalc(positional, flags, s, stdout, stderr) {
792
1394
  }
793
1395
 
794
1396
  // src/cli/commands/validate.ts
795
- import * as fs5 from "fs";
1397
+ import * as fs6 from "fs";
796
1398
  async function runValidate(positional, flags, s, stdout, stderr) {
797
1399
  const filePath = positional[0];
798
1400
  if (!filePath) {
@@ -801,7 +1403,7 @@ async function runValidate(positional, flags, s, stdout, stderr) {
801
1403
  }
802
1404
  let csv;
803
1405
  try {
804
- csv = fs5.readFileSync(filePath, "utf8");
1406
+ csv = fs6.readFileSync(filePath, "utf8");
805
1407
  } catch {
806
1408
  stderr.write(`Cannot read file: ${filePath}
807
1409
  `);
@@ -868,9 +1470,9 @@ async function runConfig(positional, flags, s, stdout, stderr) {
868
1470
  }
869
1471
 
870
1472
  // src/cli/commands/stress-test.ts
871
- import * as fs6 from "fs";
872
- import * as path4 from "path";
873
- import * as os3 from "os";
1473
+ import * as fs7 from "fs";
1474
+ import * as path5 from "path";
1475
+ import * as os4 from "os";
874
1476
  import { fileURLToPath as fileURLToPath2 } from "url";
875
1477
  import { spawnSync as spawnSync2 } from "child_process";
876
1478
 
@@ -948,8 +1550,8 @@ function generateCsv(scenario) {
948
1550
 
949
1551
  // src/stress/runner.ts
950
1552
  import { spawnSync } from "child_process";
951
- import { writeFileSync as writeFileSync3 } from "fs";
952
- import { join as join4 } from "path";
1553
+ import { writeFileSync as writeFileSync4 } from "fs";
1554
+ import { join as join5 } from "path";
953
1555
  function countWarnings(text) {
954
1556
  if (!text) return 0;
955
1557
  const match = text.match(/(?:warnings|avvertenze)\s*:\s*(\d+)/i);
@@ -1002,8 +1604,8 @@ function runCommand(cliBin, args, cmdLabel, expectedExitCode, outputShapeCheck)
1002
1604
  }
1003
1605
  function runScenario(scenario, csvContent, outputDir, cliBin) {
1004
1606
  const csvFileName = `${scenario.id}-${scenario.slug}.csv`;
1005
- const csvFile = join4(outputDir, csvFileName);
1006
- writeFileSync3(csvFile, csvContent, "utf8");
1607
+ const csvFile = join5(outputDir, csvFileName);
1608
+ writeFileSync4(csvFile, csvContent, "utf8");
1007
1609
  const results = [];
1008
1610
  results.push(
1009
1611
  runCommand(
@@ -1213,11 +1815,11 @@ async function runStressTest(_positionals, flags, stdout, stderr) {
1213
1815
  );
1214
1816
  return 2;
1215
1817
  }
1216
- const __dirname = path4.dirname(fileURLToPath2(import.meta.url));
1217
- const manifestPath = path4.join(__dirname, "../data/stress-manifest.json");
1818
+ const __dirname = path5.dirname(fileURLToPath2(import.meta.url));
1819
+ const manifestPath = path5.join(__dirname, "../data/stress-manifest.json");
1218
1820
  let manifest;
1219
1821
  try {
1220
- const raw = fs6.readFileSync(manifestPath, "utf8");
1822
+ const raw = fs7.readFileSync(manifestPath, "utf8");
1221
1823
  manifest = JSON.parse(raw);
1222
1824
  } catch {
1223
1825
  stderr.write(`Error: could not load stress manifest at ${manifestPath}
@@ -1229,9 +1831,9 @@ async function runStressTest(_positionals, flags, stdout, stderr) {
1229
1831
  const outputDirFlag = flags["output-dir"];
1230
1832
  if (outputDirFlag) {
1231
1833
  outputDir = outputDirFlag;
1232
- fs6.mkdirSync(outputDir, { recursive: true });
1834
+ fs7.mkdirSync(outputDir, { recursive: true });
1233
1835
  } else {
1234
- outputDir = fs6.mkdtempSync(path4.join(os3.tmpdir(), "minus-tracker-stress-"));
1836
+ outputDir = fs7.mkdtempSync(path5.join(os4.tmpdir(), "minus-tracker-stress-"));
1235
1837
  }
1236
1838
  const ratesCheck = spawnSync2("node", [cliBin, "rates", "--check"], {
1237
1839
  encoding: "utf8",
@@ -1268,11 +1870,122 @@ async function runStressTest(_positionals, flags, stdout, stderr) {
1268
1870
  stdout.write(formatTable(report) + "\n");
1269
1871
  }
1270
1872
  if (!flags["keep"]) {
1271
- fs6.rmSync(outputDir, { recursive: true, force: true });
1873
+ fs7.rmSync(outputDir, { recursive: true, force: true });
1272
1874
  }
1273
1875
  return report.failed === 0 ? 0 : 1;
1274
1876
  }
1275
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
+
1276
1989
  // src/cli/index.ts
1277
1990
  async function main() {
1278
1991
  const { values, positionals } = parseArgs({
@@ -1287,7 +2000,8 @@ async function main() {
1287
2000
  show: { type: "boolean", default: false },
1288
2001
  range: { type: "string" },
1289
2002
  keep: { type: "boolean", default: false },
1290
- "output-dir": { type: "string" }
2003
+ "output-dir": { type: "string" },
2004
+ offline: { type: "boolean", default: false }
1291
2005
  },
1292
2006
  allowPositionals: true,
1293
2007
  strict: false
@@ -1317,14 +2031,20 @@ async function main() {
1317
2031
  case "stress-test":
1318
2032
  exitCode = await runStressTest(restPositionals, flags, stdout, stderr);
1319
2033
  break;
2034
+ case "classify":
2035
+ exitCode = await runClassify(restPositionals, flags, s, stdout, stderr);
2036
+ break;
1320
2037
  default:
1321
2038
  stderr.write(
1322
- "Usage: minus-tracker <calc|validate|rates|config|stress-test> [options] [file]\n"
2039
+ "Usage: minus-tracker <calc|validate|rates|config|stress-test|classify> [options] [file]\n"
1323
2040
  );
1324
2041
  exitCode = 2;
1325
2042
  }
1326
2043
  } catch (err) {
1327
- if (err instanceof ParseError) {
2044
+ if (err instanceof ClassificationError) {
2045
+ stderr.write(err.message + "\n");
2046
+ exitCode = 1;
2047
+ } else if (err instanceof ParseError) {
1328
2048
  if (err.code === "INVALID_CSV") {
1329
2049
  stderr.write(s.errorInvalidCsv + "\n");
1330
2050
  } else {