@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/README.md +29 -4
- package/dist/cli/index.js +762 -42
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +461 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +60 -6
- package/dist/index.d.ts +60 -6
- package/dist/index.js +459 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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: (
|
|
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: (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
]
|
|
599
|
-
|
|
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
|
|
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
|
-
]
|
|
611
|
-
|
|
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
|
|
1181
|
+
import * as fs4 from "fs";
|
|
628
1182
|
import * as path3 from "path";
|
|
629
1183
|
import * as os2 from "os";
|
|
630
|
-
import * as
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
705
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
872
|
-
import * as
|
|
873
|
-
import * as
|
|
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
|
|
952
|
-
import { join as
|
|
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 =
|
|
1006
|
-
|
|
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 =
|
|
1217
|
-
const manifestPath =
|
|
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 =
|
|
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
|
-
|
|
1834
|
+
fs7.mkdirSync(outputDir, { recursive: true });
|
|
1233
1835
|
} else {
|
|
1234
|
-
outputDir =
|
|
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
|
-
|
|
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
|
|
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 {
|