@gabrielerandelli/minus-tracker 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -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 fs5 from "fs";
227
- import * as path4 from "path";
228
- import * as os3 from "os";
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/calculator/index.ts
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: roundHalfUp(buyCostEUR),
589
- sellProceedsEUR: roundHalfUp(sellProceedsEUR),
590
- gainLossEUR: roundHalfUp(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: roundHalfUp(g.plusvalenze),
646
- imposta: roundHalfUp(g.plusvalenze * taxRate)
934
+ plusvalenze: roundHalfUp2(g.plusvalenze),
935
+ imposta: roundHalfUp2(g.plusvalenze * taxRate)
647
936
  }));
648
937
  const bucketAReport = {
649
938
  groups: bucketAGroups,
650
- totalImposta: roundHalfUp(
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 = roundHalfUp(bPlusvalenze);
662
- bMinusvalenze = roundHalfUp(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 = roundHalfUp(carryForwardApplied);
676
- const bNetResult = roundHalfUp(
964
+ carryForwardApplied = roundHalfUp2(carryForwardApplied);
965
+ const bNetResult = roundHalfUp2(
677
966
  bPlusvalenze - bMinusvalenze - carryForwardApplied
678
967
  );
679
- const carryForwardRemaining = roundHalfUp(Math.max(0, -bNetResult));
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: roundHalfUp(plusvalenze),
691
- minusvalenze: roundHalfUp(minusvalenze),
692
- netResult: roundHalfUp(plusvalenze - minusvalenze),
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: roundHalfUp(plusvalenze),
714
- minusvalenze: roundHalfUp(minusvalenze),
715
- netResult: roundHalfUp(plusvalenze - minusvalenze),
1025
+ plusvalenze: roundHalfUp2(plusvalenze),
1026
+ minusvalenze: roundHalfUp2(minusvalenze),
1027
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
716
1028
  lots: matchedLots,
717
1029
  ratesUsed,
718
1030
  warnings,
@@ -934,7 +1246,7 @@ function classifyByType(isin, securityType, product, warnings) {
934
1246
  };
935
1247
  }
936
1248
  warnings.push(
937
- `Unrecognized type: ${securityType}. Please classify manually.`
1249
+ `Unrecognized type for ${isin}: ${securityType}. Please classify manually.`
938
1250
  );
939
1251
  return {
940
1252
  product,
@@ -947,6 +1259,84 @@ function classifyByType(isin, securityType, product, warnings) {
947
1259
  source: "user"
948
1260
  };
949
1261
  }
1262
+ var ASSET_CLASS_DEFAULTS = {
1263
+ ETF: {
1264
+ assetClass: "ETF",
1265
+ bucketGain: "A",
1266
+ bucketLoss: "B",
1267
+ taxRate: 0.26,
1268
+ whiteListed: null
1269
+ },
1270
+ Stock: {
1271
+ assetClass: "Stock",
1272
+ bucketGain: "B",
1273
+ bucketLoss: "B",
1274
+ taxRate: 0,
1275
+ whiteListed: null
1276
+ },
1277
+ ETC: {
1278
+ assetClass: "ETC",
1279
+ bucketGain: "B",
1280
+ bucketLoss: "B",
1281
+ taxRate: 0,
1282
+ whiteListed: null
1283
+ },
1284
+ GovtBondWL: {
1285
+ assetClass: "GovtBondWL",
1286
+ bucketGain: "A",
1287
+ bucketLoss: "B",
1288
+ taxRate: 0.125,
1289
+ whiteListed: true
1290
+ },
1291
+ GovtBondOther: {
1292
+ assetClass: "GovtBondOther",
1293
+ bucketGain: "A",
1294
+ bucketLoss: "B",
1295
+ taxRate: 0.26,
1296
+ whiteListed: false
1297
+ },
1298
+ CorpBond: {
1299
+ assetClass: "CorpBond",
1300
+ bucketGain: "B",
1301
+ bucketLoss: "B",
1302
+ taxRate: 0,
1303
+ whiteListed: null
1304
+ },
1305
+ Derivative: {
1306
+ assetClass: "Derivative",
1307
+ bucketGain: "B",
1308
+ bucketLoss: "B",
1309
+ taxRate: 0,
1310
+ whiteListed: null
1311
+ },
1312
+ LeverageCert: {
1313
+ assetClass: "LeverageCert",
1314
+ bucketGain: "B",
1315
+ bucketLoss: "B",
1316
+ taxRate: 0,
1317
+ whiteListed: null
1318
+ },
1319
+ CapProtectedCert: {
1320
+ assetClass: "CapProtectedCert",
1321
+ bucketGain: "A",
1322
+ bucketLoss: "B",
1323
+ taxRate: 0.26,
1324
+ whiteListed: null
1325
+ }
1326
+ };
1327
+ function buildEntryFromAssetClass(assetClass, product) {
1328
+ const mapped = ASSET_CLASS_DEFAULTS[assetClass];
1329
+ return {
1330
+ product,
1331
+ assetClass: mapped.assetClass,
1332
+ bucketGain: mapped.bucketGain,
1333
+ bucketLoss: mapped.bucketLoss,
1334
+ taxRate: mapped.taxRate,
1335
+ whiteListed: mapped.whiteListed,
1336
+ confirmedByUser: true,
1337
+ source: "user"
1338
+ };
1339
+ }
950
1340
  var Classifier = class {
951
1341
  interactive;
952
1342
  constructor(options) {
@@ -968,7 +1358,7 @@ var Classifier = class {
968
1358
  }
969
1359
  return parsed.classifications;
970
1360
  }
971
- async classify(transactions, sidecarPath, _httpPost = httpsPost) {
1361
+ async classify(transactions, sidecarPath, options, _httpPost = httpsPost) {
972
1362
  const isinToProduct = /* @__PURE__ */ new Map();
973
1363
  for (const tx of transactions) {
974
1364
  if (tx.isin && !isinToProduct.has(tx.isin)) {
@@ -976,13 +1366,21 @@ var Classifier = class {
976
1366
  }
977
1367
  }
978
1368
  const confirmed = {};
979
- if (fs3.existsSync(sidecarPath)) {
980
- const existingMap = await this.load(sidecarPath);
981
- for (const [isin, entry] of Object.entries(existingMap)) {
982
- if (entry.confirmedByUser) {
983
- confirmed[isin] = entry;
1369
+ if (sidecarPath !== void 0) {
1370
+ if (fs3.existsSync(sidecarPath)) {
1371
+ const existingMap = await this.load(sidecarPath);
1372
+ for (const [isin, entry] of Object.entries(existingMap)) {
1373
+ if (entry.confirmedByUser) {
1374
+ confirmed[isin] = entry;
1375
+ }
984
1376
  }
985
1377
  }
1378
+ } else if (options?.existingClassification) {
1379
+ Object.assign(confirmed, options.existingClassification);
1380
+ }
1381
+ for (const [isin, assetClass] of Object.entries(options?.overrides ?? {})) {
1382
+ const product = isinToProduct.get(isin) ?? isin;
1383
+ confirmed[isin] = buildEntryFromAssetClass(assetClass, product);
986
1384
  }
987
1385
  const toProcess = [];
988
1386
  for (const isin of isinToProduct.keys()) {
@@ -992,76 +1390,100 @@ var Classifier = class {
992
1390
  }
993
1391
  const warnings = [];
994
1392
  const newEntries = {};
995
- const BATCH_SIZE = 10;
996
- for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
997
- if (i > 0) {
998
- await new Promise((resolve2) => setTimeout(resolve2, 6e3));
1393
+ if (options?.offline) {
1394
+ for (const isin of toProcess) {
1395
+ const product = isinToProduct.get(isin) ?? isin;
1396
+ warnings.push(
1397
+ `Unrecognized type for ${isin}: unknown. Please classify manually.`
1398
+ );
1399
+ newEntries[isin] = {
1400
+ product,
1401
+ assetClass: "Stock",
1402
+ bucketGain: "B",
1403
+ bucketLoss: "B",
1404
+ taxRate: 0,
1405
+ whiteListed: null,
1406
+ confirmedByUser: false,
1407
+ source: "user"
1408
+ };
999
1409
  }
1000
- const batch = toProcess.slice(i, i + BATCH_SIZE);
1001
- const requestBody = JSON.stringify(
1002
- batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
1003
- );
1004
- let response = null;
1005
- for (let attempt = 0; attempt < 2; attempt++) {
1006
- try {
1007
- const r = await _httpPost(
1008
- "https://api.openfigi.com/v3/mapping",
1009
- requestBody,
1010
- 1e4
1011
- );
1012
- if (r.status < 500) {
1013
- response = r;
1014
- break;
1410
+ } else {
1411
+ const BATCH_SIZE = 10;
1412
+ const totalBatches = Math.ceil(toProcess.length / BATCH_SIZE);
1413
+ for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
1414
+ if (i > 0) {
1415
+ await new Promise((resolve2) => setTimeout(resolve2, 6e3));
1416
+ }
1417
+ const batch = toProcess.slice(i, i + BATCH_SIZE);
1418
+ const requestBody = JSON.stringify(
1419
+ batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
1420
+ );
1421
+ let response = null;
1422
+ for (let attempt = 0; attempt < 2; attempt++) {
1423
+ try {
1424
+ const r = await _httpPost(
1425
+ "https://api.openfigi.com/v3/mapping",
1426
+ requestBody,
1427
+ 1e4
1428
+ );
1429
+ if (r.status < 500) {
1430
+ response = r;
1431
+ break;
1432
+ }
1433
+ } catch {
1434
+ throw new ClassificationError("NETWORK_ERROR");
1015
1435
  }
1016
- } catch {
1436
+ }
1437
+ if (response === null) {
1017
1438
  throw new ClassificationError("NETWORK_ERROR");
1018
1439
  }
1019
- }
1020
- if (response === null) {
1021
- throw new ClassificationError("NETWORK_ERROR");
1022
- }
1023
- const results = JSON.parse(response.data);
1024
- for (let j = 0; j < batch.length; j++) {
1025
- const isin = batch[j];
1026
- const result = results[j];
1027
- const product = isinToProduct.get(isin) ?? isin;
1028
- if (!result || result.error || !result.data || result.data.length === 0) {
1029
- warnings.push(
1030
- `Unrecognized type: unknown. Please classify manually.`
1031
- );
1032
- newEntries[isin] = {
1033
- product,
1034
- assetClass: "Stock",
1035
- bucketGain: "B",
1036
- bucketLoss: "B",
1037
- taxRate: 0,
1038
- whiteListed: null,
1039
- confirmedByUser: false,
1040
- source: "user"
1041
- };
1042
- continue;
1440
+ const results = JSON.parse(response.data);
1441
+ for (let j = 0; j < batch.length; j++) {
1442
+ const isin = batch[j];
1443
+ const result = results[j];
1444
+ const product = isinToProduct.get(isin) ?? isin;
1445
+ if (!result || result.error || !result.data || result.data.length === 0) {
1446
+ warnings.push(
1447
+ `Unrecognized type for ${isin}: unknown. Please classify manually.`
1448
+ );
1449
+ newEntries[isin] = {
1450
+ product,
1451
+ assetClass: "Stock",
1452
+ bucketGain: "B",
1453
+ bucketLoss: "B",
1454
+ taxRate: 0,
1455
+ whiteListed: null,
1456
+ confirmedByUser: false,
1457
+ source: "user"
1458
+ };
1459
+ continue;
1460
+ }
1461
+ const st = result.data[0]?.securityType;
1462
+ const st2 = result.data[0]?.securityType2;
1463
+ const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
1464
+ newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
1043
1465
  }
1044
- const st = result.data[0]?.securityType;
1045
- const st2 = result.data[0]?.securityType2;
1046
- const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
1047
- newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
1466
+ const batchIndex = i / BATCH_SIZE;
1467
+ options?.onBatchProgress?.(batchIndex + 1, totalBatches);
1048
1468
  }
1049
1469
  }
1050
1470
  const mergedMap = { ...newEntries, ...confirmed };
1051
- const sidecarContent = JSON.stringify(
1052
- {
1053
- version: 1,
1054
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1055
- classifications: mergedMap,
1056
- warnings
1057
- },
1058
- null,
1059
- 2
1060
- );
1061
- try {
1062
- fs3.writeFileSync(sidecarPath, sidecarContent, "utf-8");
1063
- } catch {
1064
- throw new ClassificationError("WRITE_ERROR");
1471
+ if (sidecarPath !== void 0) {
1472
+ const sidecarContent = JSON.stringify(
1473
+ {
1474
+ version: 1,
1475
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1476
+ classifications: mergedMap,
1477
+ warnings
1478
+ },
1479
+ null,
1480
+ 2
1481
+ );
1482
+ try {
1483
+ fs3.writeFileSync(sidecarPath, sidecarContent, "utf-8");
1484
+ } catch {
1485
+ throw new ClassificationError("WRITE_ERROR");
1486
+ }
1065
1487
  }
1066
1488
  return mergedMap;
1067
1489
  }
@@ -1084,7 +1506,80 @@ function assetClassLabel(ac, s) {
1084
1506
  if (ac === "GovtBondWL" || ac === "GovtBondOther") return s.bucketABtpWl;
1085
1507
  return ac;
1086
1508
  }
1087
- function renderReport(report, s) {
1509
+ function renderDichiarazione(report, s, carryForwardWasProvided) {
1510
+ const d = report.dichiarazione;
1511
+ const rt = d.quadroRT;
1512
+ const rm = d.quadroRM;
1513
+ const fmt = (n) => formatEUR(n, s.numberLocale);
1514
+ const lines = [];
1515
+ lines.push(SEPARATOR);
1516
+ lines.push(s.dichiarazioneHeader(d.annoImposta));
1517
+ lines.push(s.dichiarazioneNota);
1518
+ lines.push("");
1519
+ lines.push(s.quadroRTHeader);
1520
+ lines.push(` ${s.quadroRTPlusvalenze} ${fmt(rt.plusvalenze)}`);
1521
+ lines.push(` ${s.quadroRTMinusvalenze} ${fmt(rt.minusvalenze)}`);
1522
+ lines.push(` ${s.quadroRTDifferenza} ${fmt(rt.differenza)}`);
1523
+ for (const entry of rt.carryForwardApplied) {
1524
+ lines.push(
1525
+ ` ${s.quadroRTRiporto(entry.annoOrigine)} ${fmt(entry.importo)}`
1526
+ );
1527
+ }
1528
+ let rtNLine = ` ${s.quadroRTImponibile} ${fmt(rt.imponibileNetto)}`;
1529
+ if (rt.differenza < 0) {
1530
+ rtNLine += ` (${s.quadroRTPerdita} ${fmt(Math.abs(rt.differenza))})`;
1531
+ }
1532
+ if (rt.differenza > 0 && !carryForwardWasProvided) {
1533
+ rtNLine += ` ${s.warnNoCarryForwardProvided}`;
1534
+ }
1535
+ lines.push(rtNLine);
1536
+ lines.push(` ${s.quadroRTImposta} ${fmt(rt.imposta)}`);
1537
+ if (rt.differenza < 0) {
1538
+ const riportato = rt.carryForwardRiportato.reduce(
1539
+ (sum, e) => sum + e.importo,
1540
+ 0
1541
+ );
1542
+ lines.push(
1543
+ ` ${s.quadroRTRiportabile} ${fmt(riportato)} ${s.bucketBCarryNote}`
1544
+ );
1545
+ }
1546
+ lines.push("");
1547
+ lines.push(s.quadroRMHeader);
1548
+ if (rm.capitaleAliquota26.plusvalenze > 0) {
1549
+ lines.push(
1550
+ ` ${s.quadroRMEtf26} ${fmt(rm.capitaleAliquota26.plusvalenze)}`
1551
+ );
1552
+ lines.push(
1553
+ ` [RM-A1] ${s.quadroRMImposta} ${fmt(rm.capitaleAliquota26.imposta)}`
1554
+ );
1555
+ }
1556
+ if (rm.capitaleAliquota125.plusvalenze > 0) {
1557
+ lines.push(
1558
+ ` ${s.quadroRMBtp} ${fmt(rm.capitaleAliquota125.plusvalenze)}`
1559
+ );
1560
+ lines.push(
1561
+ ` [RM-A2] ${s.quadroRMImposta} ${fmt(rm.capitaleAliquota125.imposta)}`
1562
+ );
1563
+ }
1564
+ for (const div of rm.dividendiEsteri) {
1565
+ lines.push(
1566
+ ` ${s.quadroRMDividendi} \u2014 ${div.prodotto} (${div.isin}) ${fmt(div.lordo)}`
1567
+ );
1568
+ lines.push(
1569
+ ` [RM-D] ${s.quadroRMRitenuta} \u2014 ${div.prodotto} (${div.isin}) ${fmt(div.rittenutaEstera)}`
1570
+ );
1571
+ }
1572
+ for (const c of rm.cedole) {
1573
+ lines.push(
1574
+ ` ${s.quadroRMCedole} \u2014 ${c.prodotto} (${c.isin}) ${fmt(c.importo)}`
1575
+ );
1576
+ }
1577
+ lines.push("");
1578
+ lines.push(s.dichiarazioneWarningRow);
1579
+ lines.push(s.dichiarazioneDisclaimer);
1580
+ return lines.join("\n");
1581
+ }
1582
+ function renderReport(report, s, carryForwardWasProvided) {
1088
1583
  const fmt = (n) => formatEUR(n, s.numberLocale);
1089
1584
  const lines = [];
1090
1585
  const hasBuckets = !!(report.bucketA || report.bucketB);
@@ -1173,111 +1668,12 @@ function renderReport(report, s) {
1173
1668
  lines.push(s.warnMixedBuckets);
1174
1669
  lines.push("");
1175
1670
  }
1176
- lines.push(s.disclaimer);
1177
- return lines.join("\n");
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");
1671
+ if (report.dichiarazione) {
1672
+ lines.push(renderDichiarazione(report, s, carryForwardWasProvided));
1190
1673
  } else {
1191
- configDir = process.env.XDG_CONFIG_HOME ?? path3.join(os2.homedir(), ".config");
1674
+ lines.push(s.disclaimer);
1192
1675
  }
1193
- return path3.join(configDir, "minus-tracker", "ecb-rates.json");
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;
1676
+ return lines.join("\n");
1281
1677
  }
1282
1678
 
1283
1679
  // src/cli/commands/calc.ts
@@ -1291,30 +1687,12 @@ async function runCalc(positional, flags, s, stdout, stderr) {
1291
1687
  }
1292
1688
  let csv;
1293
1689
  try {
1294
- csv = fs5.readFileSync(filePath, "utf8");
1690
+ csv = fs4.readFileSync(filePath, "utf8");
1295
1691
  } catch {
1296
1692
  stderr.write(`Cannot read file: ${filePath}
1297
1693
  `);
1298
1694
  return 1;
1299
1695
  }
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
1696
  const method = flags["method"] ?? "LIFO";
1319
1697
  if (method !== "LIFO" && method !== "FIFO") {
1320
1698
  stderr.write("--method must be LIFO or FIFO\n");
@@ -1322,7 +1700,7 @@ async function runCalc(positional, flags, s, stdout, stderr) {
1322
1700
  }
1323
1701
  const sidecarPath = filePath.replace(/\.csv$/i, "") + ".classify.json";
1324
1702
  let classification;
1325
- if (fs5.existsSync(sidecarPath)) {
1703
+ if (fs4.existsSync(sidecarPath)) {
1326
1704
  try {
1327
1705
  const classifier = new Classifier({ interactive: false });
1328
1706
  classification = await classifier.load(sidecarPath);
@@ -1332,6 +1710,13 @@ async function runCalc(positional, flags, s, stdout, stderr) {
1332
1710
  return 1;
1333
1711
  }
1334
1712
  }
1713
+ const exportFlagRaw = flags["export-dichiarazione"];
1714
+ const exportRequested = exportFlagRaw !== void 0 && exportFlagRaw !== false;
1715
+ const exportPath = typeof exportFlagRaw === "string" ? exportFlagRaw : filePath.replace(/\.csv$/i, "") + ".dichiarazione.json";
1716
+ if (exportRequested && classification === void 0) {
1717
+ stderr.write(s.warnNoDichiarazioneSidecar + "\n");
1718
+ return 1;
1719
+ }
1335
1720
  const rawCf = flags["carry-forward"];
1336
1721
  const rawCarryForwards = Array.isArray(rawCf) ? rawCf : rawCf ? [rawCf] : [];
1337
1722
  const cfRegex = /^\d{4}:\d+(\.\d+)?$/;
@@ -1341,16 +1726,16 @@ async function runCalc(positional, flags, s, stdout, stderr) {
1341
1726
  return 2;
1342
1727
  }
1343
1728
  }
1344
- const xdgConfig = process.env["XDG_CONFIG_HOME"] ?? path4.join(os3.homedir(), ".config");
1345
- const cfConfigPath = path4.join(
1729
+ const xdgConfig = process.env["XDG_CONFIG_HOME"] ?? path3.join(os2.homedir(), ".config");
1730
+ const cfConfigPath = path3.join(
1346
1731
  xdgConfig,
1347
1732
  "minus-tracker",
1348
1733
  "carryforward.json"
1349
1734
  );
1350
1735
  const cfFromFile = {};
1351
- if (fs5.existsSync(cfConfigPath)) {
1736
+ if (fs4.existsSync(cfConfigPath)) {
1352
1737
  try {
1353
- const raw = JSON.parse(fs5.readFileSync(cfConfigPath, "utf-8"));
1738
+ const raw = JSON.parse(fs4.readFileSync(cfConfigPath, "utf-8"));
1354
1739
  for (const entry of raw.losses ?? []) {
1355
1740
  cfFromFile[entry.year] = entry.amount;
1356
1741
  }
@@ -1382,19 +1767,36 @@ async function runCalc(positional, flags, s, stdout, stderr) {
1382
1767
  }
1383
1768
  const calculator = new Calculator(transactions, parser.warnings, {
1384
1769
  classification,
1385
- carryForward: carryForward.length > 0 ? carryForward : void 0
1770
+ carryForward: carryForward.length > 0 ? carryForward : void 0,
1771
+ incomeRows: parser.incomeRows
1386
1772
  });
1387
1773
  const report = calculator.calculateGains(method);
1774
+ const carryForwardWasProvided = carryForward.length > 0;
1775
+ if (exportRequested) {
1776
+ try {
1777
+ await report.dichiarazione.exportTo(exportPath);
1778
+ } catch {
1779
+ stderr.write(`Cannot write dichiarazione export: ${exportPath}
1780
+ `);
1781
+ return 1;
1782
+ }
1783
+ }
1388
1784
  if (flags["json"]) {
1389
- stdout.write(JSON.stringify(report, null, 2) + "\n");
1785
+ const { dichiarazione: _dichiarazione, ...jsonReport } = report;
1786
+ stdout.write(JSON.stringify(jsonReport, null, 2) + "\n");
1390
1787
  } else {
1391
- stdout.write(renderReport(report, s) + "\n");
1788
+ stdout.write(renderReport(report, s, carryForwardWasProvided) + "\n");
1789
+ }
1790
+ if (exportRequested) {
1791
+ stdout.write(s.classifyWritten(exportPath) + "\n");
1792
+ } else if (!report.dichiarazione) {
1793
+ stderr.write(s.warnNoDichiarazioneSidecar + "\n");
1392
1794
  }
1393
1795
  return 0;
1394
1796
  }
1395
1797
 
1396
1798
  // src/cli/commands/validate.ts
1397
- import * as fs6 from "fs";
1799
+ import * as fs5 from "fs";
1398
1800
  async function runValidate(positional, flags, s, stdout, stderr) {
1399
1801
  const filePath = positional[0];
1400
1802
  if (!filePath) {
@@ -1403,7 +1805,7 @@ async function runValidate(positional, flags, s, stdout, stderr) {
1403
1805
  }
1404
1806
  let csv;
1405
1807
  try {
1406
- csv = fs6.readFileSync(filePath, "utf8");
1808
+ csv = fs5.readFileSync(filePath, "utf8");
1407
1809
  } catch {
1408
1810
  stderr.write(`Cannot read file: ${filePath}
1409
1811
  `);
@@ -1440,12 +1842,121 @@ async function runValidate(positional, flags, s, stdout, stderr) {
1440
1842
  case "QUANTITY_ZERO":
1441
1843
  reason = s.warnQuantityZero(entry.row);
1442
1844
  break;
1845
+ case "MISSING_ISIN_INCOME":
1846
+ reason = s.warnMissingIsinIncome(entry.row);
1847
+ break;
1848
+ case "ORPHAN_WITHHOLDING":
1849
+ reason = s.warnOrphanWithholding(entry.isin, entry.date);
1850
+ break;
1443
1851
  }
1444
1852
  stdout.write(reason + "\n");
1445
1853
  }
1446
1854
  return 0;
1447
1855
  }
1448
1856
 
1857
+ // src/cli/commands/rates.ts
1858
+ import * as fs6 from "fs";
1859
+ import * as path4 from "path";
1860
+ import * as os3 from "os";
1861
+ import * as https2 from "https";
1862
+ function getSnapshotPath() {
1863
+ const platform = process.platform;
1864
+ let configDir;
1865
+ if (platform === "win32") {
1866
+ configDir = process.env.APPDATA ?? path4.join(os3.homedir(), "AppData", "Roaming");
1867
+ } else {
1868
+ configDir = process.env.XDG_CONFIG_HOME ?? path4.join(os3.homedir(), ".config");
1869
+ }
1870
+ return path4.join(configDir, "minus-tracker", "ecb-rates.json");
1871
+ }
1872
+ function getCoverage(snapshot) {
1873
+ let start = "9999-12-31";
1874
+ let end = "0000-01-01";
1875
+ const currencies = [];
1876
+ for (const [ccy, dates] of Object.entries(snapshot)) {
1877
+ currencies.push(ccy);
1878
+ for (const d of Object.keys(dates)) {
1879
+ if (d < start) start = d;
1880
+ if (d > end) end = d;
1881
+ }
1882
+ }
1883
+ return { start, end, currencies: currencies.sort().join(", ") };
1884
+ }
1885
+ async function fetchEcbData(currency) {
1886
+ return new Promise((resolve2, reject) => {
1887
+ const url = `https://data-api.ecb.europa.eu/service/data/EXR/D.${currency}.EUR.SP00.A?format=csvdata&startPeriod=2019-01-01`;
1888
+ https2.get(url, (res) => {
1889
+ let data = "";
1890
+ res.on("data", (chunk) => data += chunk);
1891
+ res.on("end", () => {
1892
+ const rates = {};
1893
+ const lines = data.split("\n");
1894
+ for (const line of lines) {
1895
+ const parts = line.split(",");
1896
+ if (parts.length < 8) continue;
1897
+ const date = parts[6].trim();
1898
+ const rate = parseFloat(parts[7].trim());
1899
+ if (/^\d{4}-\d{2}-\d{2}$/.test(date) && !isNaN(rate) && rate > 0) {
1900
+ rates[date] = rate;
1901
+ }
1902
+ }
1903
+ resolve2(rates);
1904
+ });
1905
+ res.on("error", reject);
1906
+ }).on("error", reject);
1907
+ });
1908
+ }
1909
+ async function updateRates(snapshotPath, stdout, stderr, s) {
1910
+ stdout.write(s.ratesUpdateFetching + "\n");
1911
+ let existing = {};
1912
+ try {
1913
+ existing = JSON.parse(
1914
+ fs6.readFileSync(snapshotPath, "utf8")
1915
+ );
1916
+ } catch {
1917
+ }
1918
+ let addedCount = 0;
1919
+ for (const currency of ["USD", "GBP", "CHF"]) {
1920
+ try {
1921
+ const newRates = await fetchEcbData(currency);
1922
+ const existing_ccy = existing[currency] ?? {};
1923
+ let added = 0;
1924
+ for (const [date, rate] of Object.entries(newRates)) {
1925
+ if (!existing_ccy[date]) added++;
1926
+ existing_ccy[date] = rate;
1927
+ }
1928
+ existing[currency] = existing_ccy;
1929
+ addedCount += added;
1930
+ } catch {
1931
+ stderr.write(`Failed to fetch ${currency} rates
1932
+ `);
1933
+ }
1934
+ }
1935
+ fs6.mkdirSync(path4.dirname(snapshotPath), { recursive: true });
1936
+ fs6.writeFileSync(
1937
+ snapshotPath,
1938
+ JSON.stringify(existing, null, 2) + "\n",
1939
+ "utf8"
1940
+ );
1941
+ stdout.write(s.ratesUpdateDone(addedCount) + "\n");
1942
+ stdout.write(s.ratesSnapshotWritten(snapshotPath) + "\n");
1943
+ }
1944
+ async function runRates(positional, flags, s, stdout, stderr) {
1945
+ if (flags["check"]) {
1946
+ const snapshot = getActiveSnapshot();
1947
+ const { start, end, currencies } = getCoverage(snapshot);
1948
+ stdout.write(s.ratesCoverage(start, end, currencies) + "\n");
1949
+ stdout.write(s.ratesGapsNone + "\n");
1950
+ return 0;
1951
+ }
1952
+ if (flags["update"]) {
1953
+ await updateRates(getSnapshotPath(), stdout, stderr, s);
1954
+ return 0;
1955
+ }
1956
+ stderr.write("Usage: minus-tracker rates --check | --update\n");
1957
+ return 2;
1958
+ }
1959
+
1449
1960
  // src/cli/commands/config.ts
1450
1961
  var SUPPORTED_LOCALES2 = ["it", "en"];
1451
1962
  async function runConfig(positional, flags, s, stdout, stderr) {