@gabrielerandelli/minus-tracker 0.5.8 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -4
- package/dist/cli/index.js +1293 -172
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +744 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +127 -6
- package/dist/index.d.ts +127 -6
- package/dist/index.js +742 -14
- package/dist/index.js.map +1 -1
- package/package.json +10 -3
package/dist/index.cjs
CHANGED
|
@@ -32,6 +32,8 @@ var src_exports = {};
|
|
|
32
32
|
__export(src_exports, {
|
|
33
33
|
CalculationError: () => CalculationError,
|
|
34
34
|
Calculator: () => Calculator,
|
|
35
|
+
ClassificationError: () => ClassificationError,
|
|
36
|
+
Classifier: () => Classifier,
|
|
35
37
|
DEGIROParser: () => DEGIROParser,
|
|
36
38
|
ParseError: () => ParseError
|
|
37
39
|
});
|
|
@@ -59,6 +61,16 @@ var CalculationError = class extends Error {
|
|
|
59
61
|
this.date = date;
|
|
60
62
|
}
|
|
61
63
|
};
|
|
64
|
+
var ClassificationError = class extends Error {
|
|
65
|
+
code;
|
|
66
|
+
constructor(code, message) {
|
|
67
|
+
super(
|
|
68
|
+
message ?? (code === "SIDECAR_NOT_FOUND" ? "Sidecar file not found" : code === "SIDECAR_VERSION" ? "Sidecar version mismatch" : code === "SIDECAR_MALFORMED" ? "Sidecar file is malformed JSON" : code === "NETWORK_ERROR" ? "Network error contacting OpenFIGI" : "Failed to write sidecar file")
|
|
69
|
+
);
|
|
70
|
+
this.name = "ClassificationError";
|
|
71
|
+
this.code = code;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
62
74
|
|
|
63
75
|
// src/rates/index.ts
|
|
64
76
|
var import_node_url = require("url");
|
|
@@ -140,6 +152,10 @@ function warningToEnglish(w) {
|
|
|
140
152
|
return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
|
|
141
153
|
case "QUANTITY_ZERO":
|
|
142
154
|
return `Row ${w.row}: quantity is 0 \u2014 skipped`;
|
|
155
|
+
case "MISSING_ISIN_INCOME":
|
|
156
|
+
return `Row ${w.row}: blank ISIN on income row \u2014 skipped`;
|
|
157
|
+
case "ORPHAN_WITHHOLDING":
|
|
158
|
+
return `Withholding row for ${w.isin} on ${w.date}: no matching income row \u2014 skipped`;
|
|
143
159
|
}
|
|
144
160
|
}
|
|
145
161
|
|
|
@@ -155,6 +171,8 @@ var REQUIRED_COLUMNS = [
|
|
|
155
171
|
"Transaction costs currency",
|
|
156
172
|
"Product"
|
|
157
173
|
];
|
|
174
|
+
var INCOME_KEYWORDS = ["DIVIDEND", "COUPON", "INTEREST", "CEDOLA"];
|
|
175
|
+
var TAX_KEYWORDS = ["DIVIDEND TAX", "WITHHOLDING", "RITENUTA"];
|
|
158
176
|
function parseCSVRow(line) {
|
|
159
177
|
const fields = [];
|
|
160
178
|
let i = 0;
|
|
@@ -196,6 +214,7 @@ function parseDate(ddmmyyyy) {
|
|
|
196
214
|
var DEGIROParser = class {
|
|
197
215
|
_warningEntries = [];
|
|
198
216
|
_snapshot;
|
|
217
|
+
_incomeRows = [];
|
|
199
218
|
constructor(snapshot) {
|
|
200
219
|
this._snapshot = snapshot;
|
|
201
220
|
}
|
|
@@ -212,6 +231,7 @@ var DEGIROParser = class {
|
|
|
212
231
|
*/
|
|
213
232
|
parse(csv) {
|
|
214
233
|
this._warningEntries = [];
|
|
234
|
+
this._incomeRows = [];
|
|
215
235
|
if (typeof csv !== "string" || csv.includes("\0")) {
|
|
216
236
|
throw new ParseError("INVALID_CSV");
|
|
217
237
|
}
|
|
@@ -236,17 +256,70 @@ var DEGIROParser = class {
|
|
|
236
256
|
}
|
|
237
257
|
const snapshot = this._snapshot ?? getActiveSnapshot();
|
|
238
258
|
const transactions = [];
|
|
259
|
+
const incomeCandidates = [];
|
|
260
|
+
const withholdingCandidates = [];
|
|
239
261
|
for (let r = 1; r < rows.length; r++) {
|
|
240
262
|
const row = rows[r];
|
|
241
263
|
const rowIndex = r + 1;
|
|
242
264
|
if (row.every((cell) => cell.trim() === "")) continue;
|
|
243
265
|
const get = (col) => (row[colIndex[col]] ?? "").trim();
|
|
244
266
|
const isin = get("ISIN");
|
|
267
|
+
const product = get("Product");
|
|
268
|
+
const qtyStr = get("Quantity");
|
|
269
|
+
const rawQty = parseFloat(qtyStr);
|
|
270
|
+
const qtyIsZeroOrBlank = qtyStr === "" || isNaN(rawQty) || rawQty === 0;
|
|
271
|
+
if (qtyIsZeroOrBlank) {
|
|
272
|
+
const upperProduct = product.toUpperCase();
|
|
273
|
+
const isTaxKeyword = TAX_KEYWORDS.some((k) => upperProduct.includes(k));
|
|
274
|
+
const isIncomeKeyword = !isTaxKeyword && INCOME_KEYWORDS.some((k) => upperProduct.includes(k));
|
|
275
|
+
if (isTaxKeyword || isIncomeKeyword) {
|
|
276
|
+
const rawLocalValue = parseFloat(get("Local value"));
|
|
277
|
+
const localValue = isNaN(rawLocalValue) ? 0 : rawLocalValue;
|
|
278
|
+
const isoDate2 = parseDate(get("Date"));
|
|
279
|
+
if (isTaxKeyword && localValue < 0) {
|
|
280
|
+
if (!isin) {
|
|
281
|
+
this._warningEntries.push({
|
|
282
|
+
code: "MISSING_ISIN_INCOME",
|
|
283
|
+
row: rowIndex
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
withholdingCandidates.push({
|
|
288
|
+
isin,
|
|
289
|
+
date: isoDate2,
|
|
290
|
+
amount: Math.abs(localValue),
|
|
291
|
+
row: rowIndex
|
|
292
|
+
});
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (isIncomeKeyword && localValue > 0) {
|
|
296
|
+
if (!isin) {
|
|
297
|
+
this._warningEntries.push({
|
|
298
|
+
code: "MISSING_ISIN_INCOME",
|
|
299
|
+
row: rowIndex
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const incomeType = upperProduct.includes(
|
|
304
|
+
"DIVIDEND"
|
|
305
|
+
) ? "dividend" : "coupon";
|
|
306
|
+
incomeCandidates.push({
|
|
307
|
+
isin,
|
|
308
|
+
product,
|
|
309
|
+
date: isoDate2,
|
|
310
|
+
incomeType,
|
|
311
|
+
localValue,
|
|
312
|
+
currency: get("Local value currency"),
|
|
313
|
+
row: rowIndex
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
245
319
|
if (!isin) {
|
|
246
320
|
this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
|
|
247
321
|
continue;
|
|
248
322
|
}
|
|
249
|
-
const rawQty = parseFloat(get("Quantity"));
|
|
250
323
|
if (isNaN(rawQty) || rawQty === 0) {
|
|
251
324
|
this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
|
|
252
325
|
continue;
|
|
@@ -287,7 +360,6 @@ var DEGIROParser = class {
|
|
|
287
360
|
const rawFees = parseFloat(get("Transaction costs"));
|
|
288
361
|
const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
|
|
289
362
|
const pricePerUnit = parseFloat(get("Price"));
|
|
290
|
-
const product = get("Product");
|
|
291
363
|
transactions.push({
|
|
292
364
|
isin,
|
|
293
365
|
product,
|
|
@@ -302,6 +374,73 @@ var DEGIROParser = class {
|
|
|
302
374
|
fxRate
|
|
303
375
|
});
|
|
304
376
|
}
|
|
377
|
+
const withholdingByKey = /* @__PURE__ */ new Map();
|
|
378
|
+
for (const w of withholdingCandidates) {
|
|
379
|
+
const key = `${w.isin}|${w.date}`;
|
|
380
|
+
const existing = withholdingByKey.get(key);
|
|
381
|
+
withholdingByKey.set(key, {
|
|
382
|
+
isin: w.isin,
|
|
383
|
+
date: w.date,
|
|
384
|
+
amount: (existing?.amount ?? 0) + w.amount
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const consumedKeys = /* @__PURE__ */ new Set();
|
|
388
|
+
const incomeRows = [];
|
|
389
|
+
for (const c of incomeCandidates) {
|
|
390
|
+
const key = `${c.isin}|${c.date}`;
|
|
391
|
+
const matchedWithholding = withholdingByKey.get(key);
|
|
392
|
+
let grossAmount;
|
|
393
|
+
let withholdingTax;
|
|
394
|
+
let fxRate;
|
|
395
|
+
if (c.currency === "EUR") {
|
|
396
|
+
grossAmount = Math.abs(c.localValue);
|
|
397
|
+
withholdingTax = matchedWithholding?.amount ?? 0;
|
|
398
|
+
fxRate = void 0;
|
|
399
|
+
} else {
|
|
400
|
+
const rate = lookupRate(c.currency, c.date, snapshot);
|
|
401
|
+
if (rate === null) {
|
|
402
|
+
if (snapshot[c.currency] === void 0) {
|
|
403
|
+
this._warningEntries.push({
|
|
404
|
+
code: "UNSUPPORTED_CURRENCY",
|
|
405
|
+
row: c.row,
|
|
406
|
+
currency: c.currency
|
|
407
|
+
});
|
|
408
|
+
} else {
|
|
409
|
+
this._warningEntries.push({
|
|
410
|
+
code: "NO_ECB_RATE",
|
|
411
|
+
row: c.row,
|
|
412
|
+
currency: c.currency,
|
|
413
|
+
date: c.date
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
grossAmount = Math.abs(c.localValue) / rate;
|
|
419
|
+
withholdingTax = matchedWithholding ? matchedWithholding.amount / rate : 0;
|
|
420
|
+
fxRate = rate;
|
|
421
|
+
}
|
|
422
|
+
if (matchedWithholding) consumedKeys.add(key);
|
|
423
|
+
incomeRows.push({
|
|
424
|
+
isin: c.isin,
|
|
425
|
+
product: c.product,
|
|
426
|
+
date: c.date,
|
|
427
|
+
incomeType: c.incomeType,
|
|
428
|
+
grossAmount,
|
|
429
|
+
withholdingTax,
|
|
430
|
+
currency: c.currency,
|
|
431
|
+
fxRate
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
for (const [key, w] of withholdingByKey) {
|
|
435
|
+
if (!consumedKeys.has(key)) {
|
|
436
|
+
this._warningEntries.push({
|
|
437
|
+
code: "ORPHAN_WITHHOLDING",
|
|
438
|
+
isin: w.isin,
|
|
439
|
+
date: w.date
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
this._incomeRows = incomeRows;
|
|
305
444
|
return transactions;
|
|
306
445
|
}
|
|
307
446
|
get warnings() {
|
|
@@ -310,12 +449,136 @@ var DEGIROParser = class {
|
|
|
310
449
|
get warningEntries() {
|
|
311
450
|
return this._warningEntries;
|
|
312
451
|
}
|
|
452
|
+
get incomeRows() {
|
|
453
|
+
return this._incomeRows;
|
|
454
|
+
}
|
|
313
455
|
};
|
|
314
456
|
|
|
315
|
-
// src/
|
|
457
|
+
// src/dichiarazione/engine.ts
|
|
458
|
+
var import_promises = require("fs/promises");
|
|
316
459
|
function roundHalfUp(x) {
|
|
317
460
|
return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
|
|
318
461
|
}
|
|
462
|
+
function buildQuadroRT(bucketB, carryForward, taxYear) {
|
|
463
|
+
const plusvalenze = bucketB.plusvalenze;
|
|
464
|
+
const minusvalenze = bucketB.minusvalenze;
|
|
465
|
+
const differenza = roundHalfUp(plusvalenze - minusvalenze);
|
|
466
|
+
if (differenza > 0) {
|
|
467
|
+
const sorted = [...carryForward].sort((a, b) => a.year - b.year);
|
|
468
|
+
let remaining = differenza;
|
|
469
|
+
const carryForwardApplied = [];
|
|
470
|
+
for (const entry of sorted) {
|
|
471
|
+
if (taxYear - entry.year > 4) continue;
|
|
472
|
+
const consumed = Math.min(entry.amount, remaining);
|
|
473
|
+
if (consumed > 0) {
|
|
474
|
+
carryForwardApplied.push({
|
|
475
|
+
annoOrigine: entry.year,
|
|
476
|
+
importo: roundHalfUp(consumed)
|
|
477
|
+
});
|
|
478
|
+
remaining -= consumed;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const imponibileNetto = roundHalfUp(remaining);
|
|
482
|
+
const imposta = roundHalfUp(imponibileNetto * 0.26);
|
|
483
|
+
return {
|
|
484
|
+
plusvalenze,
|
|
485
|
+
minusvalenze,
|
|
486
|
+
differenza,
|
|
487
|
+
carryForwardApplied,
|
|
488
|
+
imponibileNetto,
|
|
489
|
+
imposta,
|
|
490
|
+
carryForwardRiportato: []
|
|
491
|
+
};
|
|
492
|
+
} else if (differenza < 0) {
|
|
493
|
+
return {
|
|
494
|
+
plusvalenze,
|
|
495
|
+
minusvalenze,
|
|
496
|
+
differenza,
|
|
497
|
+
carryForwardApplied: [],
|
|
498
|
+
imponibileNetto: 0,
|
|
499
|
+
imposta: 0,
|
|
500
|
+
carryForwardRiportato: [
|
|
501
|
+
{ annoOrigine: taxYear, importo: roundHalfUp(Math.abs(differenza)) }
|
|
502
|
+
]
|
|
503
|
+
};
|
|
504
|
+
} else {
|
|
505
|
+
return {
|
|
506
|
+
plusvalenze,
|
|
507
|
+
minusvalenze,
|
|
508
|
+
differenza: 0,
|
|
509
|
+
carryForwardApplied: [],
|
|
510
|
+
imponibileNetto: 0,
|
|
511
|
+
imposta: 0,
|
|
512
|
+
carryForwardRiportato: []
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function buildQuadroRM(bucketA, incomeRows, taxYear) {
|
|
517
|
+
const groups = bucketA?.groups ?? [];
|
|
518
|
+
const group26 = groups.find((g) => g.taxRate === 0.26);
|
|
519
|
+
const group125 = groups.find((g) => g.taxRate === 0.125);
|
|
520
|
+
const capitaleAliquota26 = {
|
|
521
|
+
plusvalenze: group26?.plusvalenze ?? 0,
|
|
522
|
+
imposta: group26?.imposta ?? 0
|
|
523
|
+
};
|
|
524
|
+
const capitaleAliquota125 = {
|
|
525
|
+
plusvalenze: group125?.plusvalenze ?? 0,
|
|
526
|
+
imposta: group125?.imposta ?? 0
|
|
527
|
+
};
|
|
528
|
+
const dividendiEsteri = incomeRows.filter((r) => r.incomeType === "dividend").map((r) => ({
|
|
529
|
+
isin: r.isin,
|
|
530
|
+
prodotto: r.product,
|
|
531
|
+
lordo: r.grossAmount,
|
|
532
|
+
rittenutaEstera: r.withholdingTax
|
|
533
|
+
}));
|
|
534
|
+
const cedole = incomeRows.filter((r) => r.incomeType === "coupon").map((r) => ({
|
|
535
|
+
isin: r.isin,
|
|
536
|
+
prodotto: r.product,
|
|
537
|
+
importo: r.grossAmount,
|
|
538
|
+
rittenutaEstera: r.withholdingTax
|
|
539
|
+
}));
|
|
540
|
+
return {
|
|
541
|
+
capitaleAliquota26,
|
|
542
|
+
capitaleAliquota125,
|
|
543
|
+
dividendiEsteri,
|
|
544
|
+
cedole
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function buildDichiarazioneReport(quadroRT, quadroRM, taxYear) {
|
|
548
|
+
const version = 1;
|
|
549
|
+
const modello = "Redditi PF";
|
|
550
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
551
|
+
return {
|
|
552
|
+
version,
|
|
553
|
+
annoImposta: taxYear,
|
|
554
|
+
modello,
|
|
555
|
+
generatedAt,
|
|
556
|
+
quadroRT,
|
|
557
|
+
quadroRM,
|
|
558
|
+
exportTo: async (path2) => {
|
|
559
|
+
await (0, import_promises.writeFile)(
|
|
560
|
+
path2,
|
|
561
|
+
JSON.stringify(
|
|
562
|
+
{
|
|
563
|
+
version,
|
|
564
|
+
annoImposta: taxYear,
|
|
565
|
+
modello,
|
|
566
|
+
generatedAt,
|
|
567
|
+
quadroRT,
|
|
568
|
+
quadroRM
|
|
569
|
+
},
|
|
570
|
+
null,
|
|
571
|
+
2
|
|
572
|
+
)
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/calculator/index.ts
|
|
579
|
+
function roundHalfUp2(x) {
|
|
580
|
+
return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
|
|
581
|
+
}
|
|
319
582
|
function inferTaxYear(transactions) {
|
|
320
583
|
const counts = {};
|
|
321
584
|
for (const t of transactions) {
|
|
@@ -333,13 +596,11 @@ function inferTaxYear(transactions) {
|
|
|
333
596
|
var Calculator = class {
|
|
334
597
|
_transactions;
|
|
335
598
|
_parseWarnings;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
* @param parseWarnings - Warnings collected during the import step; forwarded into the report.
|
|
339
|
-
*/
|
|
340
|
-
constructor(transactions, parseWarnings) {
|
|
599
|
+
_options;
|
|
600
|
+
constructor(transactions, parseWarnings, options) {
|
|
341
601
|
this._transactions = transactions;
|
|
342
602
|
this._parseWarnings = parseWarnings ?? [];
|
|
603
|
+
this._options = options ?? {};
|
|
343
604
|
}
|
|
344
605
|
/**
|
|
345
606
|
* Run LIFO or FIFO lot-matching over the transaction list.
|
|
@@ -409,9 +670,9 @@ var Calculator = class {
|
|
|
409
670
|
sellDate: tx.date,
|
|
410
671
|
buyPriceEUR: lot.pricePerUnitEUR,
|
|
411
672
|
sellPriceEUR: sellPricePerUnitEUR,
|
|
412
|
-
buyCostEUR:
|
|
413
|
-
sellProceedsEUR:
|
|
414
|
-
gainLossEUR:
|
|
673
|
+
buyCostEUR: roundHalfUp2(buyCostEUR),
|
|
674
|
+
sellProceedsEUR: roundHalfUp2(sellProceedsEUR),
|
|
675
|
+
gainLossEUR: roundHalfUp2(gainLossEUR),
|
|
415
676
|
buyFxRate: lot.fxRate,
|
|
416
677
|
sellFxRate: tx.fxRate
|
|
417
678
|
});
|
|
@@ -433,12 +694,133 @@ var Calculator = class {
|
|
|
433
694
|
if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
|
|
434
695
|
else minusvalenze += Math.abs(lot.gainLossEUR);
|
|
435
696
|
}
|
|
697
|
+
if (this._options.classification) {
|
|
698
|
+
const classification = this._options.classification;
|
|
699
|
+
const unclassifiedIsins = /* @__PURE__ */ new Set();
|
|
700
|
+
for (const lot of matchedLots) {
|
|
701
|
+
const entry = classification[lot.isin];
|
|
702
|
+
if (!entry) {
|
|
703
|
+
lot.bucket = "B";
|
|
704
|
+
unclassifiedIsins.add(lot.isin);
|
|
705
|
+
} else if (entry.bucketGain === "A" && lot.gainLossEUR >= 0) {
|
|
706
|
+
lot.bucket = "A";
|
|
707
|
+
} else {
|
|
708
|
+
lot.bucket = "B";
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
for (const isin of unclassifiedIsins) {
|
|
712
|
+
warnings.push(
|
|
713
|
+
`ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
const bucketALots = matchedLots.filter((l) => l.bucket === "A");
|
|
717
|
+
const groupsByRate = /* @__PURE__ */ new Map();
|
|
718
|
+
for (const lot of bucketALots) {
|
|
719
|
+
const entry = classification[lot.isin];
|
|
720
|
+
const rate = entry.taxRate;
|
|
721
|
+
if (!groupsByRate.has(rate))
|
|
722
|
+
groupsByRate.set(rate, { assetClasses: /* @__PURE__ */ new Set(), plusvalenze: 0 });
|
|
723
|
+
const g = groupsByRate.get(rate);
|
|
724
|
+
g.assetClasses.add(entry.assetClass);
|
|
725
|
+
g.plusvalenze += lot.gainLossEUR;
|
|
726
|
+
}
|
|
727
|
+
const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
|
|
728
|
+
taxRate,
|
|
729
|
+
assetClasses: [...g.assetClasses],
|
|
730
|
+
plusvalenze: roundHalfUp2(g.plusvalenze),
|
|
731
|
+
imposta: roundHalfUp2(g.plusvalenze * taxRate)
|
|
732
|
+
}));
|
|
733
|
+
const bucketAReport = {
|
|
734
|
+
groups: bucketAGroups,
|
|
735
|
+
totalImposta: roundHalfUp2(
|
|
736
|
+
bucketAGroups.reduce((s, g) => s + g.imposta, 0)
|
|
737
|
+
)
|
|
738
|
+
};
|
|
739
|
+
const bucketBLots = matchedLots.filter((l) => l.bucket === "B");
|
|
740
|
+
let bPlusvalenze = 0;
|
|
741
|
+
let bMinusvalenze = 0;
|
|
742
|
+
for (const lot of bucketBLots) {
|
|
743
|
+
if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
|
|
744
|
+
else bMinusvalenze += Math.abs(lot.gainLossEUR);
|
|
745
|
+
}
|
|
746
|
+
bPlusvalenze = roundHalfUp2(bPlusvalenze);
|
|
747
|
+
bMinusvalenze = roundHalfUp2(bMinusvalenze);
|
|
748
|
+
const carryForwards = [...this._options.carryForward ?? []].sort(
|
|
749
|
+
(a, b) => a.year - b.year
|
|
750
|
+
);
|
|
751
|
+
let remaining = bPlusvalenze - bMinusvalenze;
|
|
752
|
+
let carryForwardApplied = 0;
|
|
753
|
+
for (const entry of carryForwards) {
|
|
754
|
+
if (taxYear - entry.year > 4) continue;
|
|
755
|
+
if (remaining <= 0) break;
|
|
756
|
+
const consumed = Math.min(entry.amount, remaining);
|
|
757
|
+
carryForwardApplied += consumed;
|
|
758
|
+
remaining -= consumed;
|
|
759
|
+
}
|
|
760
|
+
carryForwardApplied = roundHalfUp2(carryForwardApplied);
|
|
761
|
+
const bNetResult = roundHalfUp2(
|
|
762
|
+
bPlusvalenze - bMinusvalenze - carryForwardApplied
|
|
763
|
+
);
|
|
764
|
+
const carryForwardRemaining = roundHalfUp2(Math.max(0, -bNetResult));
|
|
765
|
+
const bucketBReport = {
|
|
766
|
+
plusvalenze: bPlusvalenze,
|
|
767
|
+
minusvalenze: bMinusvalenze,
|
|
768
|
+
carryForwardApplied,
|
|
769
|
+
carryForwardRemaining,
|
|
770
|
+
netResult: bNetResult
|
|
771
|
+
};
|
|
772
|
+
const allIncomeRows = this._options.incomeRows ?? [];
|
|
773
|
+
const filteredIncomeRows = allIncomeRows.filter(
|
|
774
|
+
(row) => new Date(row.date).getFullYear() === taxYear
|
|
775
|
+
);
|
|
776
|
+
if (filteredIncomeRows.length < allIncomeRows.length) {
|
|
777
|
+
warnings.push(`Income rows outside tax year ${taxYear} were skipped.`);
|
|
778
|
+
}
|
|
779
|
+
const quadroRT = buildQuadroRT(
|
|
780
|
+
bucketBReport,
|
|
781
|
+
this._options.carryForward ?? [],
|
|
782
|
+
taxYear
|
|
783
|
+
);
|
|
784
|
+
const quadroRM = buildQuadroRM(
|
|
785
|
+
bucketAGroups.length > 0 ? bucketAReport : void 0,
|
|
786
|
+
filteredIncomeRows,
|
|
787
|
+
taxYear
|
|
788
|
+
);
|
|
789
|
+
const dichiarazioneReport = buildDichiarazioneReport(
|
|
790
|
+
quadroRT,
|
|
791
|
+
quadroRM,
|
|
792
|
+
taxYear
|
|
793
|
+
);
|
|
794
|
+
return {
|
|
795
|
+
method,
|
|
796
|
+
taxYear,
|
|
797
|
+
plusvalenze: roundHalfUp2(plusvalenze),
|
|
798
|
+
minusvalenze: roundHalfUp2(minusvalenze),
|
|
799
|
+
netResult: roundHalfUp2(plusvalenze - minusvalenze),
|
|
800
|
+
lots: matchedLots,
|
|
801
|
+
ratesUsed,
|
|
802
|
+
warnings,
|
|
803
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
804
|
+
bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
|
|
805
|
+
bucketB: bucketBReport,
|
|
806
|
+
dichiarazione: dichiarazioneReport
|
|
807
|
+
};
|
|
808
|
+
} else {
|
|
809
|
+
const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
|
|
810
|
+
const hasIePrefix = allIsins.some((isin) => isin.startsWith("IE"));
|
|
811
|
+
const hasOther = allIsins.some((isin) => !isin.startsWith("IE"));
|
|
812
|
+
if (hasIePrefix && hasOther) {
|
|
813
|
+
warnings.push(
|
|
814
|
+
"AVVISO: CSV contiene tipi di strumenti misti (es. ETF + Azioni).\n Il calcolo a bucket unico pu\xF2 non essere fiscalmente corretto.\n Esegui: minus-tracker classify trades.csv"
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
436
818
|
return {
|
|
437
819
|
method,
|
|
438
820
|
taxYear,
|
|
439
|
-
plusvalenze:
|
|
440
|
-
minusvalenze:
|
|
441
|
-
netResult:
|
|
821
|
+
plusvalenze: roundHalfUp2(plusvalenze),
|
|
822
|
+
minusvalenze: roundHalfUp2(minusvalenze),
|
|
823
|
+
netResult: roundHalfUp2(plusvalenze - minusvalenze),
|
|
442
824
|
lots: matchedLots,
|
|
443
825
|
ratesUsed,
|
|
444
826
|
warnings,
|
|
@@ -446,10 +828,358 @@ var Calculator = class {
|
|
|
446
828
|
};
|
|
447
829
|
}
|
|
448
830
|
};
|
|
831
|
+
|
|
832
|
+
// src/classifier/index.ts
|
|
833
|
+
var fs2 = __toESM(require("fs"), 1);
|
|
834
|
+
var https = __toESM(require("https"), 1);
|
|
835
|
+
function httpsPost(url, body, timeoutMs) {
|
|
836
|
+
return new Promise((resolve, reject) => {
|
|
837
|
+
const u = new URL(url);
|
|
838
|
+
const req = https.request(
|
|
839
|
+
{
|
|
840
|
+
hostname: u.hostname,
|
|
841
|
+
path: u.pathname + u.search,
|
|
842
|
+
method: "POST",
|
|
843
|
+
headers: {
|
|
844
|
+
"Content-Type": "application/json",
|
|
845
|
+
"Content-Length": Buffer.byteLength(body)
|
|
846
|
+
}
|
|
847
|
+
},
|
|
848
|
+
(res) => {
|
|
849
|
+
let data = "";
|
|
850
|
+
res.on("data", (chunk) => {
|
|
851
|
+
data += chunk;
|
|
852
|
+
});
|
|
853
|
+
res.on("end", () => resolve({ status: res.statusCode ?? 0, data }));
|
|
854
|
+
}
|
|
855
|
+
);
|
|
856
|
+
req.setTimeout(timeoutMs, () => {
|
|
857
|
+
req.destroy();
|
|
858
|
+
reject(new Error("timeout"));
|
|
859
|
+
});
|
|
860
|
+
req.on("error", reject);
|
|
861
|
+
req.write(body);
|
|
862
|
+
req.end();
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
var GOVT_BOND_WHITELIST = /* @__PURE__ */ new Set([
|
|
866
|
+
"IT",
|
|
867
|
+
"DE",
|
|
868
|
+
"FR",
|
|
869
|
+
"AT",
|
|
870
|
+
"BE",
|
|
871
|
+
"NL",
|
|
872
|
+
"ES",
|
|
873
|
+
"PT",
|
|
874
|
+
"FI",
|
|
875
|
+
"IE",
|
|
876
|
+
"LU",
|
|
877
|
+
"GR",
|
|
878
|
+
"SK",
|
|
879
|
+
"SI",
|
|
880
|
+
"LT",
|
|
881
|
+
"LV",
|
|
882
|
+
"EE",
|
|
883
|
+
"MT",
|
|
884
|
+
"CY",
|
|
885
|
+
"US",
|
|
886
|
+
"GB",
|
|
887
|
+
"CH",
|
|
888
|
+
"NO",
|
|
889
|
+
"SE",
|
|
890
|
+
"DK",
|
|
891
|
+
"JP",
|
|
892
|
+
"CA",
|
|
893
|
+
"AU",
|
|
894
|
+
"NZ",
|
|
895
|
+
"SG",
|
|
896
|
+
"HK",
|
|
897
|
+
"KR"
|
|
898
|
+
]);
|
|
899
|
+
var SECURITY_TYPE_MAP = {
|
|
900
|
+
ETP: {
|
|
901
|
+
assetClass: "ETF",
|
|
902
|
+
bucketGain: "A",
|
|
903
|
+
bucketLoss: "B",
|
|
904
|
+
taxRate: 0.26,
|
|
905
|
+
whiteListed: null
|
|
906
|
+
},
|
|
907
|
+
ETF: {
|
|
908
|
+
assetClass: "ETF",
|
|
909
|
+
bucketGain: "A",
|
|
910
|
+
bucketLoss: "B",
|
|
911
|
+
taxRate: 0.26,
|
|
912
|
+
whiteListed: null
|
|
913
|
+
},
|
|
914
|
+
"Mutual Fund": {
|
|
915
|
+
assetClass: "ETF",
|
|
916
|
+
bucketGain: "A",
|
|
917
|
+
bucketLoss: "B",
|
|
918
|
+
taxRate: 0.26,
|
|
919
|
+
whiteListed: null
|
|
920
|
+
},
|
|
921
|
+
"Open-End Fund": {
|
|
922
|
+
assetClass: "ETF",
|
|
923
|
+
bucketGain: "A",
|
|
924
|
+
bucketLoss: "B",
|
|
925
|
+
taxRate: 0.26,
|
|
926
|
+
whiteListed: null
|
|
927
|
+
},
|
|
928
|
+
"Common Stock": {
|
|
929
|
+
assetClass: "Stock",
|
|
930
|
+
bucketGain: "B",
|
|
931
|
+
bucketLoss: "B",
|
|
932
|
+
taxRate: 0,
|
|
933
|
+
whiteListed: null
|
|
934
|
+
},
|
|
935
|
+
Equity: {
|
|
936
|
+
assetClass: "Stock",
|
|
937
|
+
bucketGain: "B",
|
|
938
|
+
bucketLoss: "B",
|
|
939
|
+
taxRate: 0,
|
|
940
|
+
whiteListed: null
|
|
941
|
+
},
|
|
942
|
+
"Exchange Traded Commodity": {
|
|
943
|
+
assetClass: "ETC",
|
|
944
|
+
bucketGain: "B",
|
|
945
|
+
bucketLoss: "B",
|
|
946
|
+
taxRate: 0,
|
|
947
|
+
whiteListed: null
|
|
948
|
+
},
|
|
949
|
+
"Corporate Bond": {
|
|
950
|
+
assetClass: "CorpBond",
|
|
951
|
+
bucketGain: "B",
|
|
952
|
+
bucketLoss: "B",
|
|
953
|
+
taxRate: 0,
|
|
954
|
+
whiteListed: null
|
|
955
|
+
},
|
|
956
|
+
Option: {
|
|
957
|
+
assetClass: "Derivative",
|
|
958
|
+
bucketGain: "B",
|
|
959
|
+
bucketLoss: "B",
|
|
960
|
+
taxRate: 0,
|
|
961
|
+
whiteListed: null
|
|
962
|
+
},
|
|
963
|
+
Future: {
|
|
964
|
+
assetClass: "Derivative",
|
|
965
|
+
bucketGain: "B",
|
|
966
|
+
bucketLoss: "B",
|
|
967
|
+
taxRate: 0,
|
|
968
|
+
whiteListed: null
|
|
969
|
+
},
|
|
970
|
+
Warrant: {
|
|
971
|
+
assetClass: "Derivative",
|
|
972
|
+
bucketGain: "B",
|
|
973
|
+
bucketLoss: "B",
|
|
974
|
+
taxRate: 0,
|
|
975
|
+
whiteListed: null
|
|
976
|
+
},
|
|
977
|
+
CFD: {
|
|
978
|
+
assetClass: "Derivative",
|
|
979
|
+
bucketGain: "B",
|
|
980
|
+
bucketLoss: "B",
|
|
981
|
+
taxRate: 0,
|
|
982
|
+
whiteListed: null
|
|
983
|
+
},
|
|
984
|
+
Swap: {
|
|
985
|
+
assetClass: "Derivative",
|
|
986
|
+
bucketGain: "B",
|
|
987
|
+
bucketLoss: "B",
|
|
988
|
+
taxRate: 0,
|
|
989
|
+
whiteListed: null
|
|
990
|
+
},
|
|
991
|
+
"Leverage Certificate": {
|
|
992
|
+
assetClass: "LeverageCert",
|
|
993
|
+
bucketGain: "B",
|
|
994
|
+
bucketLoss: "B",
|
|
995
|
+
taxRate: 0,
|
|
996
|
+
whiteListed: null
|
|
997
|
+
},
|
|
998
|
+
"Turbo Certificate": {
|
|
999
|
+
assetClass: "LeverageCert",
|
|
1000
|
+
bucketGain: "B",
|
|
1001
|
+
bucketLoss: "B",
|
|
1002
|
+
taxRate: 0,
|
|
1003
|
+
whiteListed: null
|
|
1004
|
+
},
|
|
1005
|
+
"Capital Protected Certificate": {
|
|
1006
|
+
assetClass: "CapProtectedCert",
|
|
1007
|
+
bucketGain: "A",
|
|
1008
|
+
bucketLoss: "B",
|
|
1009
|
+
taxRate: 0.26,
|
|
1010
|
+
whiteListed: null
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
function isKnownType(t) {
|
|
1014
|
+
return t === "Government Bond" || t in SECURITY_TYPE_MAP;
|
|
1015
|
+
}
|
|
1016
|
+
function classifyByType(isin, securityType, product, warnings) {
|
|
1017
|
+
if (securityType === "Government Bond") {
|
|
1018
|
+
const prefix = isin.slice(0, 2).toUpperCase();
|
|
1019
|
+
const whiteListed = GOVT_BOND_WHITELIST.has(prefix);
|
|
1020
|
+
return {
|
|
1021
|
+
product,
|
|
1022
|
+
assetClass: whiteListed ? "GovtBondWL" : "GovtBondOther",
|
|
1023
|
+
bucketGain: "A",
|
|
1024
|
+
bucketLoss: "B",
|
|
1025
|
+
taxRate: whiteListed ? 0.125 : 0.26,
|
|
1026
|
+
whiteListed,
|
|
1027
|
+
confirmedByUser: false,
|
|
1028
|
+
source: "openfigi"
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
const mapped = SECURITY_TYPE_MAP[securityType];
|
|
1032
|
+
if (mapped) {
|
|
1033
|
+
return {
|
|
1034
|
+
product,
|
|
1035
|
+
assetClass: mapped.assetClass,
|
|
1036
|
+
bucketGain: mapped.bucketGain,
|
|
1037
|
+
bucketLoss: mapped.bucketLoss,
|
|
1038
|
+
taxRate: mapped.taxRate,
|
|
1039
|
+
whiteListed: mapped.whiteListed,
|
|
1040
|
+
confirmedByUser: false,
|
|
1041
|
+
source: "openfigi"
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
warnings.push(
|
|
1045
|
+
`Unrecognized type: ${securityType}. Please classify manually.`
|
|
1046
|
+
);
|
|
1047
|
+
return {
|
|
1048
|
+
product,
|
|
1049
|
+
assetClass: "Stock",
|
|
1050
|
+
bucketGain: "B",
|
|
1051
|
+
bucketLoss: "B",
|
|
1052
|
+
taxRate: 0,
|
|
1053
|
+
whiteListed: null,
|
|
1054
|
+
confirmedByUser: false,
|
|
1055
|
+
source: "user"
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
var Classifier = class {
|
|
1059
|
+
interactive;
|
|
1060
|
+
constructor(options) {
|
|
1061
|
+
this.interactive = options?.interactive ?? true;
|
|
1062
|
+
}
|
|
1063
|
+
async load(sidecarPath) {
|
|
1064
|
+
if (!fs2.existsSync(sidecarPath)) {
|
|
1065
|
+
throw new ClassificationError("SIDECAR_NOT_FOUND");
|
|
1066
|
+
}
|
|
1067
|
+
let parsed;
|
|
1068
|
+
try {
|
|
1069
|
+
const raw = fs2.readFileSync(sidecarPath, "utf-8");
|
|
1070
|
+
parsed = JSON.parse(raw);
|
|
1071
|
+
} catch {
|
|
1072
|
+
throw new ClassificationError("SIDECAR_MALFORMED");
|
|
1073
|
+
}
|
|
1074
|
+
if (typeof parsed !== "object" || parsed === null || parsed["version"] !== 1) {
|
|
1075
|
+
throw new ClassificationError("SIDECAR_VERSION");
|
|
1076
|
+
}
|
|
1077
|
+
return parsed.classifications;
|
|
1078
|
+
}
|
|
1079
|
+
async classify(transactions, sidecarPath, _httpPost = httpsPost) {
|
|
1080
|
+
const isinToProduct = /* @__PURE__ */ new Map();
|
|
1081
|
+
for (const tx of transactions) {
|
|
1082
|
+
if (tx.isin && !isinToProduct.has(tx.isin)) {
|
|
1083
|
+
isinToProduct.set(tx.isin, tx.product);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
const confirmed = {};
|
|
1087
|
+
if (fs2.existsSync(sidecarPath)) {
|
|
1088
|
+
const existingMap = await this.load(sidecarPath);
|
|
1089
|
+
for (const [isin, entry] of Object.entries(existingMap)) {
|
|
1090
|
+
if (entry.confirmedByUser) {
|
|
1091
|
+
confirmed[isin] = entry;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const toProcess = [];
|
|
1096
|
+
for (const isin of isinToProduct.keys()) {
|
|
1097
|
+
if (!(isin in confirmed)) {
|
|
1098
|
+
toProcess.push(isin);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const warnings = [];
|
|
1102
|
+
const newEntries = {};
|
|
1103
|
+
const BATCH_SIZE = 10;
|
|
1104
|
+
for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
|
|
1105
|
+
if (i > 0) {
|
|
1106
|
+
await new Promise((resolve) => setTimeout(resolve, 6e3));
|
|
1107
|
+
}
|
|
1108
|
+
const batch = toProcess.slice(i, i + BATCH_SIZE);
|
|
1109
|
+
const requestBody = JSON.stringify(
|
|
1110
|
+
batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
|
|
1111
|
+
);
|
|
1112
|
+
let response = null;
|
|
1113
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1114
|
+
try {
|
|
1115
|
+
const r = await _httpPost(
|
|
1116
|
+
"https://api.openfigi.com/v3/mapping",
|
|
1117
|
+
requestBody,
|
|
1118
|
+
1e4
|
|
1119
|
+
);
|
|
1120
|
+
if (r.status < 500) {
|
|
1121
|
+
response = r;
|
|
1122
|
+
break;
|
|
1123
|
+
}
|
|
1124
|
+
} catch {
|
|
1125
|
+
throw new ClassificationError("NETWORK_ERROR");
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (response === null) {
|
|
1129
|
+
throw new ClassificationError("NETWORK_ERROR");
|
|
1130
|
+
}
|
|
1131
|
+
const results = JSON.parse(response.data);
|
|
1132
|
+
for (let j = 0; j < batch.length; j++) {
|
|
1133
|
+
const isin = batch[j];
|
|
1134
|
+
const result = results[j];
|
|
1135
|
+
const product = isinToProduct.get(isin) ?? isin;
|
|
1136
|
+
if (!result || result.error || !result.data || result.data.length === 0) {
|
|
1137
|
+
warnings.push(
|
|
1138
|
+
`Unrecognized type: unknown. Please classify manually.`
|
|
1139
|
+
);
|
|
1140
|
+
newEntries[isin] = {
|
|
1141
|
+
product,
|
|
1142
|
+
assetClass: "Stock",
|
|
1143
|
+
bucketGain: "B",
|
|
1144
|
+
bucketLoss: "B",
|
|
1145
|
+
taxRate: 0,
|
|
1146
|
+
whiteListed: null,
|
|
1147
|
+
confirmedByUser: false,
|
|
1148
|
+
source: "user"
|
|
1149
|
+
};
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
const st = result.data[0]?.securityType;
|
|
1153
|
+
const st2 = result.data[0]?.securityType2;
|
|
1154
|
+
const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
|
|
1155
|
+
newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
const mergedMap = { ...newEntries, ...confirmed };
|
|
1159
|
+
const sidecarContent = JSON.stringify(
|
|
1160
|
+
{
|
|
1161
|
+
version: 1,
|
|
1162
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1163
|
+
classifications: mergedMap,
|
|
1164
|
+
warnings
|
|
1165
|
+
},
|
|
1166
|
+
null,
|
|
1167
|
+
2
|
|
1168
|
+
);
|
|
1169
|
+
try {
|
|
1170
|
+
fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
|
|
1171
|
+
} catch {
|
|
1172
|
+
throw new ClassificationError("WRITE_ERROR");
|
|
1173
|
+
}
|
|
1174
|
+
return mergedMap;
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
449
1177
|
// Annotate the CommonJS export names for ESM import in node:
|
|
450
1178
|
0 && (module.exports = {
|
|
451
1179
|
CalculationError,
|
|
452
1180
|
Calculator,
|
|
1181
|
+
ClassificationError,
|
|
1182
|
+
Classifier,
|
|
453
1183
|
DEGIROParser,
|
|
454
1184
|
ParseError
|
|
455
1185
|
});
|