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