@gabrielerandelli/minus-tracker 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +572 -171
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +295 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +68 -1
- package/dist/index.d.ts +68 -1
- package/dist/index.js +295 -21
- 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",
|
|
@@ -64,7 +66,30 @@ var it = {
|
|
|
64
66
|
headerBucket: "BUCKET",
|
|
65
67
|
warnUnclassifiedIsin: (isin) => `ISIN ${isin} non trovato nella mappa di classificazione \u2014 assegnato a Bucket B.`,
|
|
66
68
|
carryForwardInvalidFormat: "Formato --carry-forward non valido. Usa: AAAA:importo (es. 2023:2500)",
|
|
67
|
-
disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale."
|
|
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)"
|
|
68
93
|
};
|
|
69
94
|
|
|
70
95
|
// src/i18n/en.ts
|
|
@@ -77,6 +102,8 @@ var en = {
|
|
|
77
102
|
warnUnsupportedCurrency: (row, currency) => `Row ${row}: unsupported currency ${currency} \u2014 skipped`,
|
|
78
103
|
warnNoEcbRate: (row, currency, date) => `Row ${row}: no ECB rate for ${currency} on ${date} \u2014 skipped`,
|
|
79
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`,
|
|
80
107
|
warnMultipleYears: "CSV contains transactions from multiple years \u2014 filter to a single year for accurate reporting.",
|
|
81
108
|
headerMethod: "METHOD",
|
|
82
109
|
headerTaxYear: "TAX YEAR",
|
|
@@ -128,7 +155,30 @@ var en = {
|
|
|
128
155
|
headerBucket: "BUCKET",
|
|
129
156
|
warnUnclassifiedIsin: (isin) => `ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`,
|
|
130
157
|
carryForwardInvalidFormat: "Invalid --carry-forward format. Use: YYYY:amount (e.g. 2023:2500)",
|
|
131
|
-
disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale."
|
|
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)"
|
|
132
182
|
};
|
|
133
183
|
|
|
134
184
|
// src/i18n/settings.ts
|
|
@@ -223,9 +273,9 @@ var ClassificationError = class extends Error {
|
|
|
223
273
|
};
|
|
224
274
|
|
|
225
275
|
// src/cli/commands/calc.ts
|
|
226
|
-
import * as
|
|
227
|
-
import * as
|
|
228
|
-
import * as
|
|
276
|
+
import * as fs4 from "fs";
|
|
277
|
+
import * as path3 from "path";
|
|
278
|
+
import * as os2 from "os";
|
|
229
279
|
|
|
230
280
|
// src/rates/index.ts
|
|
231
281
|
import { fileURLToPath } from "url";
|
|
@@ -276,18 +326,6 @@ function getActiveSnapshot() {
|
|
|
276
326
|
}
|
|
277
327
|
return merged;
|
|
278
328
|
}
|
|
279
|
-
function isSnapshotStale(snapshot, today) {
|
|
280
|
-
const todayStr = today ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
281
|
-
let maxDate = "0000-01-01";
|
|
282
|
-
for (const dates of Object.values(snapshot)) {
|
|
283
|
-
for (const d of Object.keys(dates)) {
|
|
284
|
-
if (d > maxDate) maxDate = d;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
if (maxDate === "0000-01-01") return true;
|
|
288
|
-
const diffDays = (new Date(todayStr).getTime() - new Date(maxDate).getTime()) / (1e3 * 60 * 60 * 24);
|
|
289
|
-
return diffDays >= 7;
|
|
290
|
-
}
|
|
291
329
|
function subtractDays(isoDate, days) {
|
|
292
330
|
const d = /* @__PURE__ */ new Date(isoDate + "T00:00:00Z");
|
|
293
331
|
d.setUTCDate(d.getUTCDate() - days);
|
|
@@ -318,6 +356,10 @@ function warningToEnglish(w) {
|
|
|
318
356
|
return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
|
|
319
357
|
case "QUANTITY_ZERO":
|
|
320
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`;
|
|
321
363
|
}
|
|
322
364
|
}
|
|
323
365
|
|
|
@@ -333,6 +375,8 @@ var REQUIRED_COLUMNS = [
|
|
|
333
375
|
"Transaction costs currency",
|
|
334
376
|
"Product"
|
|
335
377
|
];
|
|
378
|
+
var INCOME_KEYWORDS = ["DIVIDEND", "COUPON", "INTEREST", "CEDOLA"];
|
|
379
|
+
var TAX_KEYWORDS = ["DIVIDEND TAX", "WITHHOLDING", "RITENUTA"];
|
|
336
380
|
function parseCSVRow(line) {
|
|
337
381
|
const fields = [];
|
|
338
382
|
let i = 0;
|
|
@@ -374,6 +418,7 @@ function parseDate(ddmmyyyy) {
|
|
|
374
418
|
var DEGIROParser = class {
|
|
375
419
|
_warningEntries = [];
|
|
376
420
|
_snapshot;
|
|
421
|
+
_incomeRows = [];
|
|
377
422
|
constructor(snapshot) {
|
|
378
423
|
this._snapshot = snapshot;
|
|
379
424
|
}
|
|
@@ -390,6 +435,7 @@ var DEGIROParser = class {
|
|
|
390
435
|
*/
|
|
391
436
|
parse(csv) {
|
|
392
437
|
this._warningEntries = [];
|
|
438
|
+
this._incomeRows = [];
|
|
393
439
|
if (typeof csv !== "string" || csv.includes("\0")) {
|
|
394
440
|
throw new ParseError("INVALID_CSV");
|
|
395
441
|
}
|
|
@@ -414,17 +460,70 @@ var DEGIROParser = class {
|
|
|
414
460
|
}
|
|
415
461
|
const snapshot = this._snapshot ?? getActiveSnapshot();
|
|
416
462
|
const transactions = [];
|
|
463
|
+
const incomeCandidates = [];
|
|
464
|
+
const withholdingCandidates = [];
|
|
417
465
|
for (let r = 1; r < rows.length; r++) {
|
|
418
466
|
const row = rows[r];
|
|
419
467
|
const rowIndex = r + 1;
|
|
420
468
|
if (row.every((cell) => cell.trim() === "")) continue;
|
|
421
469
|
const get2 = (col) => (row[colIndex[col]] ?? "").trim();
|
|
422
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
|
+
}
|
|
423
523
|
if (!isin) {
|
|
424
524
|
this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
|
|
425
525
|
continue;
|
|
426
526
|
}
|
|
427
|
-
const rawQty = parseFloat(get2("Quantity"));
|
|
428
527
|
if (isNaN(rawQty) || rawQty === 0) {
|
|
429
528
|
this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
|
|
430
529
|
continue;
|
|
@@ -465,7 +564,6 @@ var DEGIROParser = class {
|
|
|
465
564
|
const rawFees = parseFloat(get2("Transaction costs"));
|
|
466
565
|
const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
|
|
467
566
|
const pricePerUnit = parseFloat(get2("Price"));
|
|
468
|
-
const product = get2("Product");
|
|
469
567
|
transactions.push({
|
|
470
568
|
isin,
|
|
471
569
|
product,
|
|
@@ -480,6 +578,73 @@ var DEGIROParser = class {
|
|
|
480
578
|
fxRate
|
|
481
579
|
});
|
|
482
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;
|
|
483
648
|
return transactions;
|
|
484
649
|
}
|
|
485
650
|
get warnings() {
|
|
@@ -488,12 +653,136 @@ var DEGIROParser = class {
|
|
|
488
653
|
get warningEntries() {
|
|
489
654
|
return this._warningEntries;
|
|
490
655
|
}
|
|
656
|
+
get incomeRows() {
|
|
657
|
+
return this._incomeRows;
|
|
658
|
+
}
|
|
491
659
|
};
|
|
492
660
|
|
|
493
|
-
// src/
|
|
661
|
+
// src/dichiarazione/engine.ts
|
|
662
|
+
import { writeFile } from "fs/promises";
|
|
494
663
|
function roundHalfUp(x) {
|
|
495
664
|
return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
|
|
496
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
|
+
}
|
|
497
786
|
function inferTaxYear(transactions) {
|
|
498
787
|
const counts = {};
|
|
499
788
|
for (const t of transactions) {
|
|
@@ -585,9 +874,9 @@ var Calculator = class {
|
|
|
585
874
|
sellDate: tx.date,
|
|
586
875
|
buyPriceEUR: lot.pricePerUnitEUR,
|
|
587
876
|
sellPriceEUR: sellPricePerUnitEUR,
|
|
588
|
-
buyCostEUR:
|
|
589
|
-
sellProceedsEUR:
|
|
590
|
-
gainLossEUR:
|
|
877
|
+
buyCostEUR: roundHalfUp2(buyCostEUR),
|
|
878
|
+
sellProceedsEUR: roundHalfUp2(sellProceedsEUR),
|
|
879
|
+
gainLossEUR: roundHalfUp2(gainLossEUR),
|
|
591
880
|
buyFxRate: lot.fxRate,
|
|
592
881
|
sellFxRate: tx.fxRate
|
|
593
882
|
});
|
|
@@ -642,12 +931,12 @@ var Calculator = class {
|
|
|
642
931
|
const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
|
|
643
932
|
taxRate,
|
|
644
933
|
assetClasses: [...g.assetClasses],
|
|
645
|
-
plusvalenze:
|
|
646
|
-
imposta:
|
|
934
|
+
plusvalenze: roundHalfUp2(g.plusvalenze),
|
|
935
|
+
imposta: roundHalfUp2(g.plusvalenze * taxRate)
|
|
647
936
|
}));
|
|
648
937
|
const bucketAReport = {
|
|
649
938
|
groups: bucketAGroups,
|
|
650
|
-
totalImposta:
|
|
939
|
+
totalImposta: roundHalfUp2(
|
|
651
940
|
bucketAGroups.reduce((s, g) => s + g.imposta, 0)
|
|
652
941
|
)
|
|
653
942
|
};
|
|
@@ -658,8 +947,8 @@ var Calculator = class {
|
|
|
658
947
|
if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
|
|
659
948
|
else bMinusvalenze += Math.abs(lot.gainLossEUR);
|
|
660
949
|
}
|
|
661
|
-
bPlusvalenze =
|
|
662
|
-
bMinusvalenze =
|
|
950
|
+
bPlusvalenze = roundHalfUp2(bPlusvalenze);
|
|
951
|
+
bMinusvalenze = roundHalfUp2(bMinusvalenze);
|
|
663
952
|
const carryForwards = [...this._options.carryForward ?? []].sort(
|
|
664
953
|
(a, b) => a.year - b.year
|
|
665
954
|
);
|
|
@@ -672,11 +961,11 @@ var Calculator = class {
|
|
|
672
961
|
carryForwardApplied += consumed;
|
|
673
962
|
remaining -= consumed;
|
|
674
963
|
}
|
|
675
|
-
carryForwardApplied =
|
|
676
|
-
const bNetResult =
|
|
964
|
+
carryForwardApplied = roundHalfUp2(carryForwardApplied);
|
|
965
|
+
const bNetResult = roundHalfUp2(
|
|
677
966
|
bPlusvalenze - bMinusvalenze - carryForwardApplied
|
|
678
967
|
);
|
|
679
|
-
const carryForwardRemaining =
|
|
968
|
+
const carryForwardRemaining = roundHalfUp2(Math.max(0, -bNetResult));
|
|
680
969
|
const bucketBReport = {
|
|
681
970
|
plusvalenze: bPlusvalenze,
|
|
682
971
|
minusvalenze: bMinusvalenze,
|
|
@@ -684,18 +973,41 @@ var Calculator = class {
|
|
|
684
973
|
carryForwardRemaining,
|
|
685
974
|
netResult: bNetResult
|
|
686
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
|
+
);
|
|
687
998
|
return {
|
|
688
999
|
method,
|
|
689
1000
|
taxYear,
|
|
690
|
-
plusvalenze:
|
|
691
|
-
minusvalenze:
|
|
692
|
-
netResult:
|
|
1001
|
+
plusvalenze: roundHalfUp2(plusvalenze),
|
|
1002
|
+
minusvalenze: roundHalfUp2(minusvalenze),
|
|
1003
|
+
netResult: roundHalfUp2(plusvalenze - minusvalenze),
|
|
693
1004
|
lots: matchedLots,
|
|
694
1005
|
ratesUsed,
|
|
695
1006
|
warnings,
|
|
696
1007
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
697
1008
|
bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
|
|
698
|
-
bucketB: bucketBReport
|
|
1009
|
+
bucketB: bucketBReport,
|
|
1010
|
+
dichiarazione: dichiarazioneReport
|
|
699
1011
|
};
|
|
700
1012
|
} else {
|
|
701
1013
|
const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
|
|
@@ -710,9 +1022,9 @@ var Calculator = class {
|
|
|
710
1022
|
return {
|
|
711
1023
|
method,
|
|
712
1024
|
taxYear,
|
|
713
|
-
plusvalenze:
|
|
714
|
-
minusvalenze:
|
|
715
|
-
netResult:
|
|
1025
|
+
plusvalenze: roundHalfUp2(plusvalenze),
|
|
1026
|
+
minusvalenze: roundHalfUp2(minusvalenze),
|
|
1027
|
+
netResult: roundHalfUp2(plusvalenze - minusvalenze),
|
|
716
1028
|
lots: matchedLots,
|
|
717
1029
|
ratesUsed,
|
|
718
1030
|
warnings,
|
|
@@ -1084,7 +1396,80 @@ function assetClassLabel(ac, s) {
|
|
|
1084
1396
|
if (ac === "GovtBondWL" || ac === "GovtBondOther") return s.bucketABtpWl;
|
|
1085
1397
|
return ac;
|
|
1086
1398
|
}
|
|
1087
|
-
function
|
|
1399
|
+
function renderDichiarazione(report, s, carryForwardWasProvided) {
|
|
1400
|
+
const d = report.dichiarazione;
|
|
1401
|
+
const rt = d.quadroRT;
|
|
1402
|
+
const rm = d.quadroRM;
|
|
1403
|
+
const fmt = (n) => formatEUR(n, s.numberLocale);
|
|
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) {
|
|
1088
1473
|
const fmt = (n) => formatEUR(n, s.numberLocale);
|
|
1089
1474
|
const lines = [];
|
|
1090
1475
|
const hasBuckets = !!(report.bucketA || report.bucketB);
|
|
@@ -1173,111 +1558,12 @@ function renderReport(report, s) {
|
|
|
1173
1558
|
lines.push(s.warnMixedBuckets);
|
|
1174
1559
|
lines.push("");
|
|
1175
1560
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
// src/cli/commands/rates.ts
|
|
1181
|
-
import * as fs4 from "fs";
|
|
1182
|
-
import * as path3 from "path";
|
|
1183
|
-
import * as os2 from "os";
|
|
1184
|
-
import * as https2 from "https";
|
|
1185
|
-
function getSnapshotPath() {
|
|
1186
|
-
const platform = process.platform;
|
|
1187
|
-
let configDir;
|
|
1188
|
-
if (platform === "win32") {
|
|
1189
|
-
configDir = process.env.APPDATA ?? path3.join(os2.homedir(), "AppData", "Roaming");
|
|
1561
|
+
if (report.dichiarazione) {
|
|
1562
|
+
lines.push(renderDichiarazione(report, s, carryForwardWasProvided));
|
|
1190
1563
|
} else {
|
|
1191
|
-
|
|
1564
|
+
lines.push(s.disclaimer);
|
|
1192
1565
|
}
|
|
1193
|
-
return
|
|
1194
|
-
}
|
|
1195
|
-
function getCoverage(snapshot) {
|
|
1196
|
-
let start = "9999-12-31";
|
|
1197
|
-
let end = "0000-01-01";
|
|
1198
|
-
const currencies = [];
|
|
1199
|
-
for (const [ccy, dates] of Object.entries(snapshot)) {
|
|
1200
|
-
currencies.push(ccy);
|
|
1201
|
-
for (const d of Object.keys(dates)) {
|
|
1202
|
-
if (d < start) start = d;
|
|
1203
|
-
if (d > end) end = d;
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
return { start, end, currencies: currencies.sort().join(", ") };
|
|
1207
|
-
}
|
|
1208
|
-
async function fetchEcbData(currency) {
|
|
1209
|
-
return new Promise((resolve2, reject) => {
|
|
1210
|
-
const url = `https://data-api.ecb.europa.eu/service/data/EXR/D.${currency}.EUR.SP00.A?format=csvdata&startPeriod=2019-01-01`;
|
|
1211
|
-
https2.get(url, (res) => {
|
|
1212
|
-
let data = "";
|
|
1213
|
-
res.on("data", (chunk) => data += chunk);
|
|
1214
|
-
res.on("end", () => {
|
|
1215
|
-
const rates = {};
|
|
1216
|
-
const lines = data.split("\n");
|
|
1217
|
-
for (const line of lines) {
|
|
1218
|
-
const parts = line.split(",");
|
|
1219
|
-
if (parts.length < 8) continue;
|
|
1220
|
-
const date = parts[6].trim();
|
|
1221
|
-
const rate = parseFloat(parts[7].trim());
|
|
1222
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(date) && !isNaN(rate) && rate > 0) {
|
|
1223
|
-
rates[date] = rate;
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
resolve2(rates);
|
|
1227
|
-
});
|
|
1228
|
-
res.on("error", reject);
|
|
1229
|
-
}).on("error", reject);
|
|
1230
|
-
});
|
|
1231
|
-
}
|
|
1232
|
-
async function updateRates(snapshotPath, stdout, stderr, s) {
|
|
1233
|
-
stdout.write(s.ratesUpdateFetching + "\n");
|
|
1234
|
-
let existing = {};
|
|
1235
|
-
try {
|
|
1236
|
-
existing = JSON.parse(
|
|
1237
|
-
fs4.readFileSync(snapshotPath, "utf8")
|
|
1238
|
-
);
|
|
1239
|
-
} catch {
|
|
1240
|
-
}
|
|
1241
|
-
let addedCount = 0;
|
|
1242
|
-
for (const currency of ["USD", "GBP", "CHF"]) {
|
|
1243
|
-
try {
|
|
1244
|
-
const newRates = await fetchEcbData(currency);
|
|
1245
|
-
const existing_ccy = existing[currency] ?? {};
|
|
1246
|
-
let added = 0;
|
|
1247
|
-
for (const [date, rate] of Object.entries(newRates)) {
|
|
1248
|
-
if (!existing_ccy[date]) added++;
|
|
1249
|
-
existing_ccy[date] = rate;
|
|
1250
|
-
}
|
|
1251
|
-
existing[currency] = existing_ccy;
|
|
1252
|
-
addedCount += added;
|
|
1253
|
-
} catch {
|
|
1254
|
-
stderr.write(`Failed to fetch ${currency} rates
|
|
1255
|
-
`);
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
fs4.mkdirSync(path3.dirname(snapshotPath), { recursive: true });
|
|
1259
|
-
fs4.writeFileSync(
|
|
1260
|
-
snapshotPath,
|
|
1261
|
-
JSON.stringify(existing, null, 2) + "\n",
|
|
1262
|
-
"utf8"
|
|
1263
|
-
);
|
|
1264
|
-
stdout.write(s.ratesUpdateDone(addedCount) + "\n");
|
|
1265
|
-
stdout.write(s.ratesSnapshotWritten(snapshotPath) + "\n");
|
|
1266
|
-
}
|
|
1267
|
-
async function runRates(positional, flags, s, stdout, stderr) {
|
|
1268
|
-
if (flags["check"]) {
|
|
1269
|
-
const snapshot = getActiveSnapshot();
|
|
1270
|
-
const { start, end, currencies } = getCoverage(snapshot);
|
|
1271
|
-
stdout.write(s.ratesCoverage(start, end, currencies) + "\n");
|
|
1272
|
-
stdout.write(s.ratesGapsNone + "\n");
|
|
1273
|
-
return 0;
|
|
1274
|
-
}
|
|
1275
|
-
if (flags["update"]) {
|
|
1276
|
-
await updateRates(getSnapshotPath(), stdout, stderr, s);
|
|
1277
|
-
return 0;
|
|
1278
|
-
}
|
|
1279
|
-
stderr.write("Usage: minus-tracker rates --check | --update\n");
|
|
1280
|
-
return 2;
|
|
1566
|
+
return lines.join("\n");
|
|
1281
1567
|
}
|
|
1282
1568
|
|
|
1283
1569
|
// src/cli/commands/calc.ts
|
|
@@ -1291,30 +1577,12 @@ async function runCalc(positional, flags, s, stdout, stderr) {
|
|
|
1291
1577
|
}
|
|
1292
1578
|
let csv;
|
|
1293
1579
|
try {
|
|
1294
|
-
csv =
|
|
1580
|
+
csv = fs4.readFileSync(filePath, "utf8");
|
|
1295
1581
|
} catch {
|
|
1296
1582
|
stderr.write(`Cannot read file: ${filePath}
|
|
1297
1583
|
`);
|
|
1298
1584
|
return 1;
|
|
1299
1585
|
}
|
|
1300
|
-
try {
|
|
1301
|
-
const snapshot = getActiveSnapshot();
|
|
1302
|
-
if (isSnapshotStale(snapshot)) {
|
|
1303
|
-
stderr.write(s.ratesAutoUpdateStart + "\n");
|
|
1304
|
-
try {
|
|
1305
|
-
await updateRates(getSnapshotPath(), stdout, stderr, s);
|
|
1306
|
-
} catch {
|
|
1307
|
-
stderr.write(s.ratesAutoUpdateFailed + "\n");
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
} catch {
|
|
1311
|
-
stderr.write(s.ratesAutoUpdateStart + "\n");
|
|
1312
|
-
try {
|
|
1313
|
-
await updateRates(getSnapshotPath(), stdout, stderr, s);
|
|
1314
|
-
} catch {
|
|
1315
|
-
stderr.write(s.ratesAutoUpdateFailed + "\n");
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
1586
|
const method = flags["method"] ?? "LIFO";
|
|
1319
1587
|
if (method !== "LIFO" && method !== "FIFO") {
|
|
1320
1588
|
stderr.write("--method must be LIFO or FIFO\n");
|
|
@@ -1322,7 +1590,7 @@ async function runCalc(positional, flags, s, stdout, stderr) {
|
|
|
1322
1590
|
}
|
|
1323
1591
|
const sidecarPath = filePath.replace(/\.csv$/i, "") + ".classify.json";
|
|
1324
1592
|
let classification;
|
|
1325
|
-
if (
|
|
1593
|
+
if (fs4.existsSync(sidecarPath)) {
|
|
1326
1594
|
try {
|
|
1327
1595
|
const classifier = new Classifier({ interactive: false });
|
|
1328
1596
|
classification = await classifier.load(sidecarPath);
|
|
@@ -1332,6 +1600,13 @@ async function runCalc(positional, flags, s, stdout, stderr) {
|
|
|
1332
1600
|
return 1;
|
|
1333
1601
|
}
|
|
1334
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
|
+
}
|
|
1335
1610
|
const rawCf = flags["carry-forward"];
|
|
1336
1611
|
const rawCarryForwards = Array.isArray(rawCf) ? rawCf : rawCf ? [rawCf] : [];
|
|
1337
1612
|
const cfRegex = /^\d{4}:\d+(\.\d+)?$/;
|
|
@@ -1341,16 +1616,16 @@ async function runCalc(positional, flags, s, stdout, stderr) {
|
|
|
1341
1616
|
return 2;
|
|
1342
1617
|
}
|
|
1343
1618
|
}
|
|
1344
|
-
const xdgConfig = process.env["XDG_CONFIG_HOME"] ??
|
|
1345
|
-
const cfConfigPath =
|
|
1619
|
+
const xdgConfig = process.env["XDG_CONFIG_HOME"] ?? path3.join(os2.homedir(), ".config");
|
|
1620
|
+
const cfConfigPath = path3.join(
|
|
1346
1621
|
xdgConfig,
|
|
1347
1622
|
"minus-tracker",
|
|
1348
1623
|
"carryforward.json"
|
|
1349
1624
|
);
|
|
1350
1625
|
const cfFromFile = {};
|
|
1351
|
-
if (
|
|
1626
|
+
if (fs4.existsSync(cfConfigPath)) {
|
|
1352
1627
|
try {
|
|
1353
|
-
const raw = JSON.parse(
|
|
1628
|
+
const raw = JSON.parse(fs4.readFileSync(cfConfigPath, "utf-8"));
|
|
1354
1629
|
for (const entry of raw.losses ?? []) {
|
|
1355
1630
|
cfFromFile[entry.year] = entry.amount;
|
|
1356
1631
|
}
|
|
@@ -1382,19 +1657,36 @@ async function runCalc(positional, flags, s, stdout, stderr) {
|
|
|
1382
1657
|
}
|
|
1383
1658
|
const calculator = new Calculator(transactions, parser.warnings, {
|
|
1384
1659
|
classification,
|
|
1385
|
-
carryForward: carryForward.length > 0 ? carryForward : void 0
|
|
1660
|
+
carryForward: carryForward.length > 0 ? carryForward : void 0,
|
|
1661
|
+
incomeRows: parser.incomeRows
|
|
1386
1662
|
});
|
|
1387
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
|
+
}
|
|
1388
1674
|
if (flags["json"]) {
|
|
1389
|
-
|
|
1675
|
+
const { dichiarazione: _dichiarazione, ...jsonReport } = report;
|
|
1676
|
+
stdout.write(JSON.stringify(jsonReport, null, 2) + "\n");
|
|
1390
1677
|
} else {
|
|
1391
|
-
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");
|
|
1392
1684
|
}
|
|
1393
1685
|
return 0;
|
|
1394
1686
|
}
|
|
1395
1687
|
|
|
1396
1688
|
// src/cli/commands/validate.ts
|
|
1397
|
-
import * as
|
|
1689
|
+
import * as fs5 from "fs";
|
|
1398
1690
|
async function runValidate(positional, flags, s, stdout, stderr) {
|
|
1399
1691
|
const filePath = positional[0];
|
|
1400
1692
|
if (!filePath) {
|
|
@@ -1403,7 +1695,7 @@ async function runValidate(positional, flags, s, stdout, stderr) {
|
|
|
1403
1695
|
}
|
|
1404
1696
|
let csv;
|
|
1405
1697
|
try {
|
|
1406
|
-
csv =
|
|
1698
|
+
csv = fs5.readFileSync(filePath, "utf8");
|
|
1407
1699
|
} catch {
|
|
1408
1700
|
stderr.write(`Cannot read file: ${filePath}
|
|
1409
1701
|
`);
|
|
@@ -1440,12 +1732,121 @@ async function runValidate(positional, flags, s, stdout, stderr) {
|
|
|
1440
1732
|
case "QUANTITY_ZERO":
|
|
1441
1733
|
reason = s.warnQuantityZero(entry.row);
|
|
1442
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;
|
|
1443
1741
|
}
|
|
1444
1742
|
stdout.write(reason + "\n");
|
|
1445
1743
|
}
|
|
1446
1744
|
return 0;
|
|
1447
1745
|
}
|
|
1448
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
|
+
|
|
1449
1850
|
// src/cli/commands/config.ts
|
|
1450
1851
|
var SUPPORTED_LOCALES2 = ["it", "en"];
|
|
1451
1852
|
async function runConfig(positional, flags, s, stdout, stderr) {
|