@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 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,
@@ -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 renderReport(report, s) {
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
- 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");
1561
+ if (report.dichiarazione) {
1562
+ lines.push(renderDichiarazione(report, s, carryForwardWasProvided));
1190
1563
  } else {
1191
- configDir = process.env.XDG_CONFIG_HOME ?? path3.join(os2.homedir(), ".config");
1564
+ lines.push(s.disclaimer);
1192
1565
  }
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;
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 = fs5.readFileSync(filePath, "utf8");
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 (fs5.existsSync(sidecarPath)) {
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"] ?? path4.join(os3.homedir(), ".config");
1345
- const cfConfigPath = path4.join(
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 (fs5.existsSync(cfConfigPath)) {
1626
+ if (fs4.existsSync(cfConfigPath)) {
1352
1627
  try {
1353
- const raw = JSON.parse(fs5.readFileSync(cfConfigPath, "utf-8"));
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
- stdout.write(JSON.stringify(report, null, 2) + "\n");
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 fs6 from "fs";
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 = fs6.readFileSync(filePath, "utf8");
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) {