@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.js
CHANGED
|
@@ -20,6 +20,16 @@ var CalculationError = class extends Error {
|
|
|
20
20
|
this.date = date;
|
|
21
21
|
}
|
|
22
22
|
};
|
|
23
|
+
var ClassificationError = class extends Error {
|
|
24
|
+
code;
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(
|
|
27
|
+
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")
|
|
28
|
+
);
|
|
29
|
+
this.name = "ClassificationError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
23
33
|
|
|
24
34
|
// src/rates/index.ts
|
|
25
35
|
import { fileURLToPath } from "url";
|
|
@@ -100,6 +110,10 @@ function warningToEnglish(w) {
|
|
|
100
110
|
return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
|
|
101
111
|
case "QUANTITY_ZERO":
|
|
102
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`;
|
|
103
117
|
}
|
|
104
118
|
}
|
|
105
119
|
|
|
@@ -115,6 +129,8 @@ var REQUIRED_COLUMNS = [
|
|
|
115
129
|
"Transaction costs currency",
|
|
116
130
|
"Product"
|
|
117
131
|
];
|
|
132
|
+
var INCOME_KEYWORDS = ["DIVIDEND", "COUPON", "INTEREST", "CEDOLA"];
|
|
133
|
+
var TAX_KEYWORDS = ["DIVIDEND TAX", "WITHHOLDING", "RITENUTA"];
|
|
118
134
|
function parseCSVRow(line) {
|
|
119
135
|
const fields = [];
|
|
120
136
|
let i = 0;
|
|
@@ -156,6 +172,7 @@ function parseDate(ddmmyyyy) {
|
|
|
156
172
|
var DEGIROParser = class {
|
|
157
173
|
_warningEntries = [];
|
|
158
174
|
_snapshot;
|
|
175
|
+
_incomeRows = [];
|
|
159
176
|
constructor(snapshot) {
|
|
160
177
|
this._snapshot = snapshot;
|
|
161
178
|
}
|
|
@@ -172,6 +189,7 @@ var DEGIROParser = class {
|
|
|
172
189
|
*/
|
|
173
190
|
parse(csv) {
|
|
174
191
|
this._warningEntries = [];
|
|
192
|
+
this._incomeRows = [];
|
|
175
193
|
if (typeof csv !== "string" || csv.includes("\0")) {
|
|
176
194
|
throw new ParseError("INVALID_CSV");
|
|
177
195
|
}
|
|
@@ -196,17 +214,70 @@ var DEGIROParser = class {
|
|
|
196
214
|
}
|
|
197
215
|
const snapshot = this._snapshot ?? getActiveSnapshot();
|
|
198
216
|
const transactions = [];
|
|
217
|
+
const incomeCandidates = [];
|
|
218
|
+
const withholdingCandidates = [];
|
|
199
219
|
for (let r = 1; r < rows.length; r++) {
|
|
200
220
|
const row = rows[r];
|
|
201
221
|
const rowIndex = r + 1;
|
|
202
222
|
if (row.every((cell) => cell.trim() === "")) continue;
|
|
203
223
|
const get = (col) => (row[colIndex[col]] ?? "").trim();
|
|
204
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
|
+
}
|
|
205
277
|
if (!isin) {
|
|
206
278
|
this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
|
|
207
279
|
continue;
|
|
208
280
|
}
|
|
209
|
-
const rawQty = parseFloat(get("Quantity"));
|
|
210
281
|
if (isNaN(rawQty) || rawQty === 0) {
|
|
211
282
|
this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
|
|
212
283
|
continue;
|
|
@@ -247,7 +318,6 @@ var DEGIROParser = class {
|
|
|
247
318
|
const rawFees = parseFloat(get("Transaction costs"));
|
|
248
319
|
const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
|
|
249
320
|
const pricePerUnit = parseFloat(get("Price"));
|
|
250
|
-
const product = get("Product");
|
|
251
321
|
transactions.push({
|
|
252
322
|
isin,
|
|
253
323
|
product,
|
|
@@ -262,6 +332,73 @@ var DEGIROParser = class {
|
|
|
262
332
|
fxRate
|
|
263
333
|
});
|
|
264
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;
|
|
265
402
|
return transactions;
|
|
266
403
|
}
|
|
267
404
|
get warnings() {
|
|
@@ -270,12 +407,136 @@ var DEGIROParser = class {
|
|
|
270
407
|
get warningEntries() {
|
|
271
408
|
return this._warningEntries;
|
|
272
409
|
}
|
|
410
|
+
get incomeRows() {
|
|
411
|
+
return this._incomeRows;
|
|
412
|
+
}
|
|
273
413
|
};
|
|
274
414
|
|
|
275
|
-
// src/
|
|
415
|
+
// src/dichiarazione/engine.ts
|
|
416
|
+
import { writeFile } from "fs/promises";
|
|
276
417
|
function roundHalfUp(x) {
|
|
277
418
|
return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
|
|
278
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
|
+
}
|
|
279
540
|
function inferTaxYear(transactions) {
|
|
280
541
|
const counts = {};
|
|
281
542
|
for (const t of transactions) {
|
|
@@ -293,13 +554,11 @@ function inferTaxYear(transactions) {
|
|
|
293
554
|
var Calculator = class {
|
|
294
555
|
_transactions;
|
|
295
556
|
_parseWarnings;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
* @param parseWarnings - Warnings collected during the import step; forwarded into the report.
|
|
299
|
-
*/
|
|
300
|
-
constructor(transactions, parseWarnings) {
|
|
557
|
+
_options;
|
|
558
|
+
constructor(transactions, parseWarnings, options) {
|
|
301
559
|
this._transactions = transactions;
|
|
302
560
|
this._parseWarnings = parseWarnings ?? [];
|
|
561
|
+
this._options = options ?? {};
|
|
303
562
|
}
|
|
304
563
|
/**
|
|
305
564
|
* Run LIFO or FIFO lot-matching over the transaction list.
|
|
@@ -369,9 +628,9 @@ var Calculator = class {
|
|
|
369
628
|
sellDate: tx.date,
|
|
370
629
|
buyPriceEUR: lot.pricePerUnitEUR,
|
|
371
630
|
sellPriceEUR: sellPricePerUnitEUR,
|
|
372
|
-
buyCostEUR:
|
|
373
|
-
sellProceedsEUR:
|
|
374
|
-
gainLossEUR:
|
|
631
|
+
buyCostEUR: roundHalfUp2(buyCostEUR),
|
|
632
|
+
sellProceedsEUR: roundHalfUp2(sellProceedsEUR),
|
|
633
|
+
gainLossEUR: roundHalfUp2(gainLossEUR),
|
|
375
634
|
buyFxRate: lot.fxRate,
|
|
376
635
|
sellFxRate: tx.fxRate
|
|
377
636
|
});
|
|
@@ -393,12 +652,133 @@ var Calculator = class {
|
|
|
393
652
|
if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
|
|
394
653
|
else minusvalenze += Math.abs(lot.gainLossEUR);
|
|
395
654
|
}
|
|
655
|
+
if (this._options.classification) {
|
|
656
|
+
const classification = this._options.classification;
|
|
657
|
+
const unclassifiedIsins = /* @__PURE__ */ new Set();
|
|
658
|
+
for (const lot of matchedLots) {
|
|
659
|
+
const entry = classification[lot.isin];
|
|
660
|
+
if (!entry) {
|
|
661
|
+
lot.bucket = "B";
|
|
662
|
+
unclassifiedIsins.add(lot.isin);
|
|
663
|
+
} else if (entry.bucketGain === "A" && lot.gainLossEUR >= 0) {
|
|
664
|
+
lot.bucket = "A";
|
|
665
|
+
} else {
|
|
666
|
+
lot.bucket = "B";
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
for (const isin of unclassifiedIsins) {
|
|
670
|
+
warnings.push(
|
|
671
|
+
`ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
const bucketALots = matchedLots.filter((l) => l.bucket === "A");
|
|
675
|
+
const groupsByRate = /* @__PURE__ */ new Map();
|
|
676
|
+
for (const lot of bucketALots) {
|
|
677
|
+
const entry = classification[lot.isin];
|
|
678
|
+
const rate = entry.taxRate;
|
|
679
|
+
if (!groupsByRate.has(rate))
|
|
680
|
+
groupsByRate.set(rate, { assetClasses: /* @__PURE__ */ new Set(), plusvalenze: 0 });
|
|
681
|
+
const g = groupsByRate.get(rate);
|
|
682
|
+
g.assetClasses.add(entry.assetClass);
|
|
683
|
+
g.plusvalenze += lot.gainLossEUR;
|
|
684
|
+
}
|
|
685
|
+
const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
|
|
686
|
+
taxRate,
|
|
687
|
+
assetClasses: [...g.assetClasses],
|
|
688
|
+
plusvalenze: roundHalfUp2(g.plusvalenze),
|
|
689
|
+
imposta: roundHalfUp2(g.plusvalenze * taxRate)
|
|
690
|
+
}));
|
|
691
|
+
const bucketAReport = {
|
|
692
|
+
groups: bucketAGroups,
|
|
693
|
+
totalImposta: roundHalfUp2(
|
|
694
|
+
bucketAGroups.reduce((s, g) => s + g.imposta, 0)
|
|
695
|
+
)
|
|
696
|
+
};
|
|
697
|
+
const bucketBLots = matchedLots.filter((l) => l.bucket === "B");
|
|
698
|
+
let bPlusvalenze = 0;
|
|
699
|
+
let bMinusvalenze = 0;
|
|
700
|
+
for (const lot of bucketBLots) {
|
|
701
|
+
if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
|
|
702
|
+
else bMinusvalenze += Math.abs(lot.gainLossEUR);
|
|
703
|
+
}
|
|
704
|
+
bPlusvalenze = roundHalfUp2(bPlusvalenze);
|
|
705
|
+
bMinusvalenze = roundHalfUp2(bMinusvalenze);
|
|
706
|
+
const carryForwards = [...this._options.carryForward ?? []].sort(
|
|
707
|
+
(a, b) => a.year - b.year
|
|
708
|
+
);
|
|
709
|
+
let remaining = bPlusvalenze - bMinusvalenze;
|
|
710
|
+
let carryForwardApplied = 0;
|
|
711
|
+
for (const entry of carryForwards) {
|
|
712
|
+
if (taxYear - entry.year > 4) continue;
|
|
713
|
+
if (remaining <= 0) break;
|
|
714
|
+
const consumed = Math.min(entry.amount, remaining);
|
|
715
|
+
carryForwardApplied += consumed;
|
|
716
|
+
remaining -= consumed;
|
|
717
|
+
}
|
|
718
|
+
carryForwardApplied = roundHalfUp2(carryForwardApplied);
|
|
719
|
+
const bNetResult = roundHalfUp2(
|
|
720
|
+
bPlusvalenze - bMinusvalenze - carryForwardApplied
|
|
721
|
+
);
|
|
722
|
+
const carryForwardRemaining = roundHalfUp2(Math.max(0, -bNetResult));
|
|
723
|
+
const bucketBReport = {
|
|
724
|
+
plusvalenze: bPlusvalenze,
|
|
725
|
+
minusvalenze: bMinusvalenze,
|
|
726
|
+
carryForwardApplied,
|
|
727
|
+
carryForwardRemaining,
|
|
728
|
+
netResult: bNetResult
|
|
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
|
+
);
|
|
752
|
+
return {
|
|
753
|
+
method,
|
|
754
|
+
taxYear,
|
|
755
|
+
plusvalenze: roundHalfUp2(plusvalenze),
|
|
756
|
+
minusvalenze: roundHalfUp2(minusvalenze),
|
|
757
|
+
netResult: roundHalfUp2(plusvalenze - minusvalenze),
|
|
758
|
+
lots: matchedLots,
|
|
759
|
+
ratesUsed,
|
|
760
|
+
warnings,
|
|
761
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
762
|
+
bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
|
|
763
|
+
bucketB: bucketBReport,
|
|
764
|
+
dichiarazione: dichiarazioneReport
|
|
765
|
+
};
|
|
766
|
+
} else {
|
|
767
|
+
const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
|
|
768
|
+
const hasIePrefix = allIsins.some((isin) => isin.startsWith("IE"));
|
|
769
|
+
const hasOther = allIsins.some((isin) => !isin.startsWith("IE"));
|
|
770
|
+
if (hasIePrefix && hasOther) {
|
|
771
|
+
warnings.push(
|
|
772
|
+
"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"
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
396
776
|
return {
|
|
397
777
|
method,
|
|
398
778
|
taxYear,
|
|
399
|
-
plusvalenze:
|
|
400
|
-
minusvalenze:
|
|
401
|
-
netResult:
|
|
779
|
+
plusvalenze: roundHalfUp2(plusvalenze),
|
|
780
|
+
minusvalenze: roundHalfUp2(minusvalenze),
|
|
781
|
+
netResult: roundHalfUp2(plusvalenze - minusvalenze),
|
|
402
782
|
lots: matchedLots,
|
|
403
783
|
ratesUsed,
|
|
404
784
|
warnings,
|
|
@@ -406,9 +786,357 @@ var Calculator = class {
|
|
|
406
786
|
};
|
|
407
787
|
}
|
|
408
788
|
};
|
|
789
|
+
|
|
790
|
+
// src/classifier/index.ts
|
|
791
|
+
import * as fs2 from "fs";
|
|
792
|
+
import * as https from "https";
|
|
793
|
+
function httpsPost(url, body, timeoutMs) {
|
|
794
|
+
return new Promise((resolve, reject) => {
|
|
795
|
+
const u = new URL(url);
|
|
796
|
+
const req = https.request(
|
|
797
|
+
{
|
|
798
|
+
hostname: u.hostname,
|
|
799
|
+
path: u.pathname + u.search,
|
|
800
|
+
method: "POST",
|
|
801
|
+
headers: {
|
|
802
|
+
"Content-Type": "application/json",
|
|
803
|
+
"Content-Length": Buffer.byteLength(body)
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
(res) => {
|
|
807
|
+
let data = "";
|
|
808
|
+
res.on("data", (chunk) => {
|
|
809
|
+
data += chunk;
|
|
810
|
+
});
|
|
811
|
+
res.on("end", () => resolve({ status: res.statusCode ?? 0, data }));
|
|
812
|
+
}
|
|
813
|
+
);
|
|
814
|
+
req.setTimeout(timeoutMs, () => {
|
|
815
|
+
req.destroy();
|
|
816
|
+
reject(new Error("timeout"));
|
|
817
|
+
});
|
|
818
|
+
req.on("error", reject);
|
|
819
|
+
req.write(body);
|
|
820
|
+
req.end();
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
var GOVT_BOND_WHITELIST = /* @__PURE__ */ new Set([
|
|
824
|
+
"IT",
|
|
825
|
+
"DE",
|
|
826
|
+
"FR",
|
|
827
|
+
"AT",
|
|
828
|
+
"BE",
|
|
829
|
+
"NL",
|
|
830
|
+
"ES",
|
|
831
|
+
"PT",
|
|
832
|
+
"FI",
|
|
833
|
+
"IE",
|
|
834
|
+
"LU",
|
|
835
|
+
"GR",
|
|
836
|
+
"SK",
|
|
837
|
+
"SI",
|
|
838
|
+
"LT",
|
|
839
|
+
"LV",
|
|
840
|
+
"EE",
|
|
841
|
+
"MT",
|
|
842
|
+
"CY",
|
|
843
|
+
"US",
|
|
844
|
+
"GB",
|
|
845
|
+
"CH",
|
|
846
|
+
"NO",
|
|
847
|
+
"SE",
|
|
848
|
+
"DK",
|
|
849
|
+
"JP",
|
|
850
|
+
"CA",
|
|
851
|
+
"AU",
|
|
852
|
+
"NZ",
|
|
853
|
+
"SG",
|
|
854
|
+
"HK",
|
|
855
|
+
"KR"
|
|
856
|
+
]);
|
|
857
|
+
var SECURITY_TYPE_MAP = {
|
|
858
|
+
ETP: {
|
|
859
|
+
assetClass: "ETF",
|
|
860
|
+
bucketGain: "A",
|
|
861
|
+
bucketLoss: "B",
|
|
862
|
+
taxRate: 0.26,
|
|
863
|
+
whiteListed: null
|
|
864
|
+
},
|
|
865
|
+
ETF: {
|
|
866
|
+
assetClass: "ETF",
|
|
867
|
+
bucketGain: "A",
|
|
868
|
+
bucketLoss: "B",
|
|
869
|
+
taxRate: 0.26,
|
|
870
|
+
whiteListed: null
|
|
871
|
+
},
|
|
872
|
+
"Mutual Fund": {
|
|
873
|
+
assetClass: "ETF",
|
|
874
|
+
bucketGain: "A",
|
|
875
|
+
bucketLoss: "B",
|
|
876
|
+
taxRate: 0.26,
|
|
877
|
+
whiteListed: null
|
|
878
|
+
},
|
|
879
|
+
"Open-End Fund": {
|
|
880
|
+
assetClass: "ETF",
|
|
881
|
+
bucketGain: "A",
|
|
882
|
+
bucketLoss: "B",
|
|
883
|
+
taxRate: 0.26,
|
|
884
|
+
whiteListed: null
|
|
885
|
+
},
|
|
886
|
+
"Common Stock": {
|
|
887
|
+
assetClass: "Stock",
|
|
888
|
+
bucketGain: "B",
|
|
889
|
+
bucketLoss: "B",
|
|
890
|
+
taxRate: 0,
|
|
891
|
+
whiteListed: null
|
|
892
|
+
},
|
|
893
|
+
Equity: {
|
|
894
|
+
assetClass: "Stock",
|
|
895
|
+
bucketGain: "B",
|
|
896
|
+
bucketLoss: "B",
|
|
897
|
+
taxRate: 0,
|
|
898
|
+
whiteListed: null
|
|
899
|
+
},
|
|
900
|
+
"Exchange Traded Commodity": {
|
|
901
|
+
assetClass: "ETC",
|
|
902
|
+
bucketGain: "B",
|
|
903
|
+
bucketLoss: "B",
|
|
904
|
+
taxRate: 0,
|
|
905
|
+
whiteListed: null
|
|
906
|
+
},
|
|
907
|
+
"Corporate Bond": {
|
|
908
|
+
assetClass: "CorpBond",
|
|
909
|
+
bucketGain: "B",
|
|
910
|
+
bucketLoss: "B",
|
|
911
|
+
taxRate: 0,
|
|
912
|
+
whiteListed: null
|
|
913
|
+
},
|
|
914
|
+
Option: {
|
|
915
|
+
assetClass: "Derivative",
|
|
916
|
+
bucketGain: "B",
|
|
917
|
+
bucketLoss: "B",
|
|
918
|
+
taxRate: 0,
|
|
919
|
+
whiteListed: null
|
|
920
|
+
},
|
|
921
|
+
Future: {
|
|
922
|
+
assetClass: "Derivative",
|
|
923
|
+
bucketGain: "B",
|
|
924
|
+
bucketLoss: "B",
|
|
925
|
+
taxRate: 0,
|
|
926
|
+
whiteListed: null
|
|
927
|
+
},
|
|
928
|
+
Warrant: {
|
|
929
|
+
assetClass: "Derivative",
|
|
930
|
+
bucketGain: "B",
|
|
931
|
+
bucketLoss: "B",
|
|
932
|
+
taxRate: 0,
|
|
933
|
+
whiteListed: null
|
|
934
|
+
},
|
|
935
|
+
CFD: {
|
|
936
|
+
assetClass: "Derivative",
|
|
937
|
+
bucketGain: "B",
|
|
938
|
+
bucketLoss: "B",
|
|
939
|
+
taxRate: 0,
|
|
940
|
+
whiteListed: null
|
|
941
|
+
},
|
|
942
|
+
Swap: {
|
|
943
|
+
assetClass: "Derivative",
|
|
944
|
+
bucketGain: "B",
|
|
945
|
+
bucketLoss: "B",
|
|
946
|
+
taxRate: 0,
|
|
947
|
+
whiteListed: null
|
|
948
|
+
},
|
|
949
|
+
"Leverage Certificate": {
|
|
950
|
+
assetClass: "LeverageCert",
|
|
951
|
+
bucketGain: "B",
|
|
952
|
+
bucketLoss: "B",
|
|
953
|
+
taxRate: 0,
|
|
954
|
+
whiteListed: null
|
|
955
|
+
},
|
|
956
|
+
"Turbo Certificate": {
|
|
957
|
+
assetClass: "LeverageCert",
|
|
958
|
+
bucketGain: "B",
|
|
959
|
+
bucketLoss: "B",
|
|
960
|
+
taxRate: 0,
|
|
961
|
+
whiteListed: null
|
|
962
|
+
},
|
|
963
|
+
"Capital Protected Certificate": {
|
|
964
|
+
assetClass: "CapProtectedCert",
|
|
965
|
+
bucketGain: "A",
|
|
966
|
+
bucketLoss: "B",
|
|
967
|
+
taxRate: 0.26,
|
|
968
|
+
whiteListed: null
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
function isKnownType(t) {
|
|
972
|
+
return t === "Government Bond" || t in SECURITY_TYPE_MAP;
|
|
973
|
+
}
|
|
974
|
+
function classifyByType(isin, securityType, product, warnings) {
|
|
975
|
+
if (securityType === "Government Bond") {
|
|
976
|
+
const prefix = isin.slice(0, 2).toUpperCase();
|
|
977
|
+
const whiteListed = GOVT_BOND_WHITELIST.has(prefix);
|
|
978
|
+
return {
|
|
979
|
+
product,
|
|
980
|
+
assetClass: whiteListed ? "GovtBondWL" : "GovtBondOther",
|
|
981
|
+
bucketGain: "A",
|
|
982
|
+
bucketLoss: "B",
|
|
983
|
+
taxRate: whiteListed ? 0.125 : 0.26,
|
|
984
|
+
whiteListed,
|
|
985
|
+
confirmedByUser: false,
|
|
986
|
+
source: "openfigi"
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
const mapped = SECURITY_TYPE_MAP[securityType];
|
|
990
|
+
if (mapped) {
|
|
991
|
+
return {
|
|
992
|
+
product,
|
|
993
|
+
assetClass: mapped.assetClass,
|
|
994
|
+
bucketGain: mapped.bucketGain,
|
|
995
|
+
bucketLoss: mapped.bucketLoss,
|
|
996
|
+
taxRate: mapped.taxRate,
|
|
997
|
+
whiteListed: mapped.whiteListed,
|
|
998
|
+
confirmedByUser: false,
|
|
999
|
+
source: "openfigi"
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
warnings.push(
|
|
1003
|
+
`Unrecognized type: ${securityType}. Please classify manually.`
|
|
1004
|
+
);
|
|
1005
|
+
return {
|
|
1006
|
+
product,
|
|
1007
|
+
assetClass: "Stock",
|
|
1008
|
+
bucketGain: "B",
|
|
1009
|
+
bucketLoss: "B",
|
|
1010
|
+
taxRate: 0,
|
|
1011
|
+
whiteListed: null,
|
|
1012
|
+
confirmedByUser: false,
|
|
1013
|
+
source: "user"
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
var Classifier = class {
|
|
1017
|
+
interactive;
|
|
1018
|
+
constructor(options) {
|
|
1019
|
+
this.interactive = options?.interactive ?? true;
|
|
1020
|
+
}
|
|
1021
|
+
async load(sidecarPath) {
|
|
1022
|
+
if (!fs2.existsSync(sidecarPath)) {
|
|
1023
|
+
throw new ClassificationError("SIDECAR_NOT_FOUND");
|
|
1024
|
+
}
|
|
1025
|
+
let parsed;
|
|
1026
|
+
try {
|
|
1027
|
+
const raw = fs2.readFileSync(sidecarPath, "utf-8");
|
|
1028
|
+
parsed = JSON.parse(raw);
|
|
1029
|
+
} catch {
|
|
1030
|
+
throw new ClassificationError("SIDECAR_MALFORMED");
|
|
1031
|
+
}
|
|
1032
|
+
if (typeof parsed !== "object" || parsed === null || parsed["version"] !== 1) {
|
|
1033
|
+
throw new ClassificationError("SIDECAR_VERSION");
|
|
1034
|
+
}
|
|
1035
|
+
return parsed.classifications;
|
|
1036
|
+
}
|
|
1037
|
+
async classify(transactions, sidecarPath, _httpPost = httpsPost) {
|
|
1038
|
+
const isinToProduct = /* @__PURE__ */ new Map();
|
|
1039
|
+
for (const tx of transactions) {
|
|
1040
|
+
if (tx.isin && !isinToProduct.has(tx.isin)) {
|
|
1041
|
+
isinToProduct.set(tx.isin, tx.product);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
const confirmed = {};
|
|
1045
|
+
if (fs2.existsSync(sidecarPath)) {
|
|
1046
|
+
const existingMap = await this.load(sidecarPath);
|
|
1047
|
+
for (const [isin, entry] of Object.entries(existingMap)) {
|
|
1048
|
+
if (entry.confirmedByUser) {
|
|
1049
|
+
confirmed[isin] = entry;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
const toProcess = [];
|
|
1054
|
+
for (const isin of isinToProduct.keys()) {
|
|
1055
|
+
if (!(isin in confirmed)) {
|
|
1056
|
+
toProcess.push(isin);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const warnings = [];
|
|
1060
|
+
const newEntries = {};
|
|
1061
|
+
const BATCH_SIZE = 10;
|
|
1062
|
+
for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
|
|
1063
|
+
if (i > 0) {
|
|
1064
|
+
await new Promise((resolve) => setTimeout(resolve, 6e3));
|
|
1065
|
+
}
|
|
1066
|
+
const batch = toProcess.slice(i, i + BATCH_SIZE);
|
|
1067
|
+
const requestBody = JSON.stringify(
|
|
1068
|
+
batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
|
|
1069
|
+
);
|
|
1070
|
+
let response = null;
|
|
1071
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1072
|
+
try {
|
|
1073
|
+
const r = await _httpPost(
|
|
1074
|
+
"https://api.openfigi.com/v3/mapping",
|
|
1075
|
+
requestBody,
|
|
1076
|
+
1e4
|
|
1077
|
+
);
|
|
1078
|
+
if (r.status < 500) {
|
|
1079
|
+
response = r;
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
1082
|
+
} catch {
|
|
1083
|
+
throw new ClassificationError("NETWORK_ERROR");
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (response === null) {
|
|
1087
|
+
throw new ClassificationError("NETWORK_ERROR");
|
|
1088
|
+
}
|
|
1089
|
+
const results = JSON.parse(response.data);
|
|
1090
|
+
for (let j = 0; j < batch.length; j++) {
|
|
1091
|
+
const isin = batch[j];
|
|
1092
|
+
const result = results[j];
|
|
1093
|
+
const product = isinToProduct.get(isin) ?? isin;
|
|
1094
|
+
if (!result || result.error || !result.data || result.data.length === 0) {
|
|
1095
|
+
warnings.push(
|
|
1096
|
+
`Unrecognized type: unknown. Please classify manually.`
|
|
1097
|
+
);
|
|
1098
|
+
newEntries[isin] = {
|
|
1099
|
+
product,
|
|
1100
|
+
assetClass: "Stock",
|
|
1101
|
+
bucketGain: "B",
|
|
1102
|
+
bucketLoss: "B",
|
|
1103
|
+
taxRate: 0,
|
|
1104
|
+
whiteListed: null,
|
|
1105
|
+
confirmedByUser: false,
|
|
1106
|
+
source: "user"
|
|
1107
|
+
};
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const st = result.data[0]?.securityType;
|
|
1111
|
+
const st2 = result.data[0]?.securityType2;
|
|
1112
|
+
const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
|
|
1113
|
+
newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
const mergedMap = { ...newEntries, ...confirmed };
|
|
1117
|
+
const sidecarContent = JSON.stringify(
|
|
1118
|
+
{
|
|
1119
|
+
version: 1,
|
|
1120
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1121
|
+
classifications: mergedMap,
|
|
1122
|
+
warnings
|
|
1123
|
+
},
|
|
1124
|
+
null,
|
|
1125
|
+
2
|
|
1126
|
+
);
|
|
1127
|
+
try {
|
|
1128
|
+
fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
|
|
1129
|
+
} catch {
|
|
1130
|
+
throw new ClassificationError("WRITE_ERROR");
|
|
1131
|
+
}
|
|
1132
|
+
return mergedMap;
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
409
1135
|
export {
|
|
410
1136
|
CalculationError,
|
|
411
1137
|
Calculator,
|
|
1138
|
+
ClassificationError,
|
|
1139
|
+
Classifier,
|
|
412
1140
|
DEGIROParser,
|
|
413
1141
|
ParseError
|
|
414
1142
|
};
|