@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/index.js CHANGED
@@ -110,6 +110,10 @@ function warningToEnglish(w) {
110
110
  return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
111
111
  case "QUANTITY_ZERO":
112
112
  return `Row ${w.row}: quantity is 0 \u2014 skipped`;
113
+ case "MISSING_ISIN_INCOME":
114
+ return `Row ${w.row}: blank ISIN on income row \u2014 skipped`;
115
+ case "ORPHAN_WITHHOLDING":
116
+ return `Withholding row for ${w.isin} on ${w.date}: no matching income row \u2014 skipped`;
113
117
  }
114
118
  }
115
119
 
@@ -125,6 +129,8 @@ var REQUIRED_COLUMNS = [
125
129
  "Transaction costs currency",
126
130
  "Product"
127
131
  ];
132
+ var INCOME_KEYWORDS = ["DIVIDEND", "COUPON", "INTEREST", "CEDOLA"];
133
+ var TAX_KEYWORDS = ["DIVIDEND TAX", "WITHHOLDING", "RITENUTA"];
128
134
  function parseCSVRow(line) {
129
135
  const fields = [];
130
136
  let i = 0;
@@ -166,6 +172,7 @@ function parseDate(ddmmyyyy) {
166
172
  var DEGIROParser = class {
167
173
  _warningEntries = [];
168
174
  _snapshot;
175
+ _incomeRows = [];
169
176
  constructor(snapshot) {
170
177
  this._snapshot = snapshot;
171
178
  }
@@ -182,6 +189,7 @@ var DEGIROParser = class {
182
189
  */
183
190
  parse(csv) {
184
191
  this._warningEntries = [];
192
+ this._incomeRows = [];
185
193
  if (typeof csv !== "string" || csv.includes("\0")) {
186
194
  throw new ParseError("INVALID_CSV");
187
195
  }
@@ -206,17 +214,70 @@ var DEGIROParser = class {
206
214
  }
207
215
  const snapshot = this._snapshot ?? getActiveSnapshot();
208
216
  const transactions = [];
217
+ const incomeCandidates = [];
218
+ const withholdingCandidates = [];
209
219
  for (let r = 1; r < rows.length; r++) {
210
220
  const row = rows[r];
211
221
  const rowIndex = r + 1;
212
222
  if (row.every((cell) => cell.trim() === "")) continue;
213
223
  const get = (col) => (row[colIndex[col]] ?? "").trim();
214
224
  const isin = get("ISIN");
225
+ const product = get("Product");
226
+ const qtyStr = get("Quantity");
227
+ const rawQty = parseFloat(qtyStr);
228
+ const qtyIsZeroOrBlank = qtyStr === "" || isNaN(rawQty) || rawQty === 0;
229
+ if (qtyIsZeroOrBlank) {
230
+ const upperProduct = product.toUpperCase();
231
+ const isTaxKeyword = TAX_KEYWORDS.some((k) => upperProduct.includes(k));
232
+ const isIncomeKeyword = !isTaxKeyword && INCOME_KEYWORDS.some((k) => upperProduct.includes(k));
233
+ if (isTaxKeyword || isIncomeKeyword) {
234
+ const rawLocalValue = parseFloat(get("Local value"));
235
+ const localValue = isNaN(rawLocalValue) ? 0 : rawLocalValue;
236
+ const isoDate2 = parseDate(get("Date"));
237
+ if (isTaxKeyword && localValue < 0) {
238
+ if (!isin) {
239
+ this._warningEntries.push({
240
+ code: "MISSING_ISIN_INCOME",
241
+ row: rowIndex
242
+ });
243
+ continue;
244
+ }
245
+ withholdingCandidates.push({
246
+ isin,
247
+ date: isoDate2,
248
+ amount: Math.abs(localValue),
249
+ row: rowIndex
250
+ });
251
+ continue;
252
+ }
253
+ if (isIncomeKeyword && localValue > 0) {
254
+ if (!isin) {
255
+ this._warningEntries.push({
256
+ code: "MISSING_ISIN_INCOME",
257
+ row: rowIndex
258
+ });
259
+ continue;
260
+ }
261
+ const incomeType = upperProduct.includes(
262
+ "DIVIDEND"
263
+ ) ? "dividend" : "coupon";
264
+ incomeCandidates.push({
265
+ isin,
266
+ product,
267
+ date: isoDate2,
268
+ incomeType,
269
+ localValue,
270
+ currency: get("Local value currency"),
271
+ row: rowIndex
272
+ });
273
+ continue;
274
+ }
275
+ }
276
+ }
215
277
  if (!isin) {
216
278
  this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
217
279
  continue;
218
280
  }
219
- const rawQty = parseFloat(get("Quantity"));
220
281
  if (isNaN(rawQty) || rawQty === 0) {
221
282
  this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
222
283
  continue;
@@ -257,7 +318,6 @@ var DEGIROParser = class {
257
318
  const rawFees = parseFloat(get("Transaction costs"));
258
319
  const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
259
320
  const pricePerUnit = parseFloat(get("Price"));
260
- const product = get("Product");
261
321
  transactions.push({
262
322
  isin,
263
323
  product,
@@ -272,6 +332,73 @@ var DEGIROParser = class {
272
332
  fxRate
273
333
  });
274
334
  }
335
+ const withholdingByKey = /* @__PURE__ */ new Map();
336
+ for (const w of withholdingCandidates) {
337
+ const key = `${w.isin}|${w.date}`;
338
+ const existing = withholdingByKey.get(key);
339
+ withholdingByKey.set(key, {
340
+ isin: w.isin,
341
+ date: w.date,
342
+ amount: (existing?.amount ?? 0) + w.amount
343
+ });
344
+ }
345
+ const consumedKeys = /* @__PURE__ */ new Set();
346
+ const incomeRows = [];
347
+ for (const c of incomeCandidates) {
348
+ const key = `${c.isin}|${c.date}`;
349
+ const matchedWithholding = withholdingByKey.get(key);
350
+ let grossAmount;
351
+ let withholdingTax;
352
+ let fxRate;
353
+ if (c.currency === "EUR") {
354
+ grossAmount = Math.abs(c.localValue);
355
+ withholdingTax = matchedWithholding?.amount ?? 0;
356
+ fxRate = void 0;
357
+ } else {
358
+ const rate = lookupRate(c.currency, c.date, snapshot);
359
+ if (rate === null) {
360
+ if (snapshot[c.currency] === void 0) {
361
+ this._warningEntries.push({
362
+ code: "UNSUPPORTED_CURRENCY",
363
+ row: c.row,
364
+ currency: c.currency
365
+ });
366
+ } else {
367
+ this._warningEntries.push({
368
+ code: "NO_ECB_RATE",
369
+ row: c.row,
370
+ currency: c.currency,
371
+ date: c.date
372
+ });
373
+ }
374
+ continue;
375
+ }
376
+ grossAmount = Math.abs(c.localValue) / rate;
377
+ withholdingTax = matchedWithholding ? matchedWithholding.amount / rate : 0;
378
+ fxRate = rate;
379
+ }
380
+ if (matchedWithholding) consumedKeys.add(key);
381
+ incomeRows.push({
382
+ isin: c.isin,
383
+ product: c.product,
384
+ date: c.date,
385
+ incomeType: c.incomeType,
386
+ grossAmount,
387
+ withholdingTax,
388
+ currency: c.currency,
389
+ fxRate
390
+ });
391
+ }
392
+ for (const [key, w] of withholdingByKey) {
393
+ if (!consumedKeys.has(key)) {
394
+ this._warningEntries.push({
395
+ code: "ORPHAN_WITHHOLDING",
396
+ isin: w.isin,
397
+ date: w.date
398
+ });
399
+ }
400
+ }
401
+ this._incomeRows = incomeRows;
275
402
  return transactions;
276
403
  }
277
404
  get warnings() {
@@ -280,12 +407,136 @@ var DEGIROParser = class {
280
407
  get warningEntries() {
281
408
  return this._warningEntries;
282
409
  }
410
+ get incomeRows() {
411
+ return this._incomeRows;
412
+ }
283
413
  };
284
414
 
285
- // src/calculator/index.ts
415
+ // src/dichiarazione/engine.ts
416
+ import { writeFile } from "fs/promises";
286
417
  function roundHalfUp(x) {
287
418
  return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
288
419
  }
420
+ function buildQuadroRT(bucketB, carryForward, taxYear) {
421
+ const plusvalenze = bucketB.plusvalenze;
422
+ const minusvalenze = bucketB.minusvalenze;
423
+ const differenza = roundHalfUp(plusvalenze - minusvalenze);
424
+ if (differenza > 0) {
425
+ const sorted = [...carryForward].sort((a, b) => a.year - b.year);
426
+ let remaining = differenza;
427
+ const carryForwardApplied = [];
428
+ for (const entry of sorted) {
429
+ if (taxYear - entry.year > 4) continue;
430
+ const consumed = Math.min(entry.amount, remaining);
431
+ if (consumed > 0) {
432
+ carryForwardApplied.push({
433
+ annoOrigine: entry.year,
434
+ importo: roundHalfUp(consumed)
435
+ });
436
+ remaining -= consumed;
437
+ }
438
+ }
439
+ const imponibileNetto = roundHalfUp(remaining);
440
+ const imposta = roundHalfUp(imponibileNetto * 0.26);
441
+ return {
442
+ plusvalenze,
443
+ minusvalenze,
444
+ differenza,
445
+ carryForwardApplied,
446
+ imponibileNetto,
447
+ imposta,
448
+ carryForwardRiportato: []
449
+ };
450
+ } else if (differenza < 0) {
451
+ return {
452
+ plusvalenze,
453
+ minusvalenze,
454
+ differenza,
455
+ carryForwardApplied: [],
456
+ imponibileNetto: 0,
457
+ imposta: 0,
458
+ carryForwardRiportato: [
459
+ { annoOrigine: taxYear, importo: roundHalfUp(Math.abs(differenza)) }
460
+ ]
461
+ };
462
+ } else {
463
+ return {
464
+ plusvalenze,
465
+ minusvalenze,
466
+ differenza: 0,
467
+ carryForwardApplied: [],
468
+ imponibileNetto: 0,
469
+ imposta: 0,
470
+ carryForwardRiportato: []
471
+ };
472
+ }
473
+ }
474
+ function buildQuadroRM(bucketA, incomeRows, taxYear) {
475
+ const groups = bucketA?.groups ?? [];
476
+ const group26 = groups.find((g) => g.taxRate === 0.26);
477
+ const group125 = groups.find((g) => g.taxRate === 0.125);
478
+ const capitaleAliquota26 = {
479
+ plusvalenze: group26?.plusvalenze ?? 0,
480
+ imposta: group26?.imposta ?? 0
481
+ };
482
+ const capitaleAliquota125 = {
483
+ plusvalenze: group125?.plusvalenze ?? 0,
484
+ imposta: group125?.imposta ?? 0
485
+ };
486
+ const dividendiEsteri = incomeRows.filter((r) => r.incomeType === "dividend").map((r) => ({
487
+ isin: r.isin,
488
+ prodotto: r.product,
489
+ lordo: r.grossAmount,
490
+ rittenutaEstera: r.withholdingTax
491
+ }));
492
+ const cedole = incomeRows.filter((r) => r.incomeType === "coupon").map((r) => ({
493
+ isin: r.isin,
494
+ prodotto: r.product,
495
+ importo: r.grossAmount,
496
+ rittenutaEstera: r.withholdingTax
497
+ }));
498
+ return {
499
+ capitaleAliquota26,
500
+ capitaleAliquota125,
501
+ dividendiEsteri,
502
+ cedole
503
+ };
504
+ }
505
+ function buildDichiarazioneReport(quadroRT, quadroRM, taxYear) {
506
+ const version = 1;
507
+ const modello = "Redditi PF";
508
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
509
+ return {
510
+ version,
511
+ annoImposta: taxYear,
512
+ modello,
513
+ generatedAt,
514
+ quadroRT,
515
+ quadroRM,
516
+ exportTo: async (path2) => {
517
+ await writeFile(
518
+ path2,
519
+ JSON.stringify(
520
+ {
521
+ version,
522
+ annoImposta: taxYear,
523
+ modello,
524
+ generatedAt,
525
+ quadroRT,
526
+ quadroRM
527
+ },
528
+ null,
529
+ 2
530
+ )
531
+ );
532
+ }
533
+ };
534
+ }
535
+
536
+ // src/calculator/index.ts
537
+ function roundHalfUp2(x) {
538
+ return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
539
+ }
289
540
  function inferTaxYear(transactions) {
290
541
  const counts = {};
291
542
  for (const t of transactions) {
@@ -377,9 +628,9 @@ var Calculator = class {
377
628
  sellDate: tx.date,
378
629
  buyPriceEUR: lot.pricePerUnitEUR,
379
630
  sellPriceEUR: sellPricePerUnitEUR,
380
- buyCostEUR: roundHalfUp(buyCostEUR),
381
- sellProceedsEUR: roundHalfUp(sellProceedsEUR),
382
- gainLossEUR: roundHalfUp(gainLossEUR),
631
+ buyCostEUR: roundHalfUp2(buyCostEUR),
632
+ sellProceedsEUR: roundHalfUp2(sellProceedsEUR),
633
+ gainLossEUR: roundHalfUp2(gainLossEUR),
383
634
  buyFxRate: lot.fxRate,
384
635
  sellFxRate: tx.fxRate
385
636
  });
@@ -434,12 +685,12 @@ var Calculator = class {
434
685
  const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
435
686
  taxRate,
436
687
  assetClasses: [...g.assetClasses],
437
- plusvalenze: roundHalfUp(g.plusvalenze),
438
- imposta: roundHalfUp(g.plusvalenze * taxRate)
688
+ plusvalenze: roundHalfUp2(g.plusvalenze),
689
+ imposta: roundHalfUp2(g.plusvalenze * taxRate)
439
690
  }));
440
691
  const bucketAReport = {
441
692
  groups: bucketAGroups,
442
- totalImposta: roundHalfUp(
693
+ totalImposta: roundHalfUp2(
443
694
  bucketAGroups.reduce((s, g) => s + g.imposta, 0)
444
695
  )
445
696
  };
@@ -450,8 +701,8 @@ var Calculator = class {
450
701
  if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
451
702
  else bMinusvalenze += Math.abs(lot.gainLossEUR);
452
703
  }
453
- bPlusvalenze = roundHalfUp(bPlusvalenze);
454
- bMinusvalenze = roundHalfUp(bMinusvalenze);
704
+ bPlusvalenze = roundHalfUp2(bPlusvalenze);
705
+ bMinusvalenze = roundHalfUp2(bMinusvalenze);
455
706
  const carryForwards = [...this._options.carryForward ?? []].sort(
456
707
  (a, b) => a.year - b.year
457
708
  );
@@ -464,11 +715,11 @@ var Calculator = class {
464
715
  carryForwardApplied += consumed;
465
716
  remaining -= consumed;
466
717
  }
467
- carryForwardApplied = roundHalfUp(carryForwardApplied);
468
- const bNetResult = roundHalfUp(
718
+ carryForwardApplied = roundHalfUp2(carryForwardApplied);
719
+ const bNetResult = roundHalfUp2(
469
720
  bPlusvalenze - bMinusvalenze - carryForwardApplied
470
721
  );
471
- const carryForwardRemaining = roundHalfUp(Math.max(0, -bNetResult));
722
+ const carryForwardRemaining = roundHalfUp2(Math.max(0, -bNetResult));
472
723
  const bucketBReport = {
473
724
  plusvalenze: bPlusvalenze,
474
725
  minusvalenze: bMinusvalenze,
@@ -476,18 +727,41 @@ var Calculator = class {
476
727
  carryForwardRemaining,
477
728
  netResult: bNetResult
478
729
  };
730
+ const allIncomeRows = this._options.incomeRows ?? [];
731
+ const filteredIncomeRows = allIncomeRows.filter(
732
+ (row) => new Date(row.date).getFullYear() === taxYear
733
+ );
734
+ if (filteredIncomeRows.length < allIncomeRows.length) {
735
+ warnings.push(`Income rows outside tax year ${taxYear} were skipped.`);
736
+ }
737
+ const quadroRT = buildQuadroRT(
738
+ bucketBReport,
739
+ this._options.carryForward ?? [],
740
+ taxYear
741
+ );
742
+ const quadroRM = buildQuadroRM(
743
+ bucketAGroups.length > 0 ? bucketAReport : void 0,
744
+ filteredIncomeRows,
745
+ taxYear
746
+ );
747
+ const dichiarazioneReport = buildDichiarazioneReport(
748
+ quadroRT,
749
+ quadroRM,
750
+ taxYear
751
+ );
479
752
  return {
480
753
  method,
481
754
  taxYear,
482
- plusvalenze: roundHalfUp(plusvalenze),
483
- minusvalenze: roundHalfUp(minusvalenze),
484
- netResult: roundHalfUp(plusvalenze - minusvalenze),
755
+ plusvalenze: roundHalfUp2(plusvalenze),
756
+ minusvalenze: roundHalfUp2(minusvalenze),
757
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
485
758
  lots: matchedLots,
486
759
  ratesUsed,
487
760
  warnings,
488
761
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
489
762
  bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
490
- bucketB: bucketBReport
763
+ bucketB: bucketBReport,
764
+ dichiarazione: dichiarazioneReport
491
765
  };
492
766
  } else {
493
767
  const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
@@ -502,9 +776,9 @@ var Calculator = class {
502
776
  return {
503
777
  method,
504
778
  taxYear,
505
- plusvalenze: roundHalfUp(plusvalenze),
506
- minusvalenze: roundHalfUp(minusvalenze),
507
- netResult: roundHalfUp(plusvalenze - minusvalenze),
779
+ plusvalenze: roundHalfUp2(plusvalenze),
780
+ minusvalenze: roundHalfUp2(minusvalenze),
781
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
508
782
  lots: matchedLots,
509
783
  ratesUsed,
510
784
  warnings,