@gabrielerandelli/minus-tracker 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -30
- package/dist/cli/index.js +751 -240
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +474 -90
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -2
- package/dist/index.d.ts +79 -2
- package/dist/index.js +474 -90
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.js +1885 -0
- package/dist/mcp/index.js.map +1 -0
- package/package.json +18 -5
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/
|
|
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:
|
|
381
|
-
sellProceedsEUR:
|
|
382
|
-
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:
|
|
438
|
-
imposta:
|
|
688
|
+
plusvalenze: roundHalfUp2(g.plusvalenze),
|
|
689
|
+
imposta: roundHalfUp2(g.plusvalenze * taxRate)
|
|
439
690
|
}));
|
|
440
691
|
const bucketAReport = {
|
|
441
692
|
groups: bucketAGroups,
|
|
442
|
-
totalImposta:
|
|
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 =
|
|
454
|
-
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 =
|
|
468
|
-
const bNetResult =
|
|
718
|
+
carryForwardApplied = roundHalfUp2(carryForwardApplied);
|
|
719
|
+
const bNetResult = roundHalfUp2(
|
|
469
720
|
bPlusvalenze - bMinusvalenze - carryForwardApplied
|
|
470
721
|
);
|
|
471
|
-
const carryForwardRemaining =
|
|
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:
|
|
483
|
-
minusvalenze:
|
|
484
|
-
netResult:
|
|
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:
|
|
506
|
-
minusvalenze:
|
|
507
|
-
netResult:
|
|
779
|
+
plusvalenze: roundHalfUp2(plusvalenze),
|
|
780
|
+
minusvalenze: roundHalfUp2(minusvalenze),
|
|
781
|
+
netResult: roundHalfUp2(plusvalenze - minusvalenze),
|
|
508
782
|
lots: matchedLots,
|
|
509
783
|
ratesUsed,
|
|
510
784
|
warnings,
|
|
@@ -726,7 +1000,7 @@ function classifyByType(isin, securityType, product, warnings) {
|
|
|
726
1000
|
};
|
|
727
1001
|
}
|
|
728
1002
|
warnings.push(
|
|
729
|
-
`Unrecognized type: ${securityType}. Please classify manually.`
|
|
1003
|
+
`Unrecognized type for ${isin}: ${securityType}. Please classify manually.`
|
|
730
1004
|
);
|
|
731
1005
|
return {
|
|
732
1006
|
product,
|
|
@@ -739,6 +1013,84 @@ function classifyByType(isin, securityType, product, warnings) {
|
|
|
739
1013
|
source: "user"
|
|
740
1014
|
};
|
|
741
1015
|
}
|
|
1016
|
+
var ASSET_CLASS_DEFAULTS = {
|
|
1017
|
+
ETF: {
|
|
1018
|
+
assetClass: "ETF",
|
|
1019
|
+
bucketGain: "A",
|
|
1020
|
+
bucketLoss: "B",
|
|
1021
|
+
taxRate: 0.26,
|
|
1022
|
+
whiteListed: null
|
|
1023
|
+
},
|
|
1024
|
+
Stock: {
|
|
1025
|
+
assetClass: "Stock",
|
|
1026
|
+
bucketGain: "B",
|
|
1027
|
+
bucketLoss: "B",
|
|
1028
|
+
taxRate: 0,
|
|
1029
|
+
whiteListed: null
|
|
1030
|
+
},
|
|
1031
|
+
ETC: {
|
|
1032
|
+
assetClass: "ETC",
|
|
1033
|
+
bucketGain: "B",
|
|
1034
|
+
bucketLoss: "B",
|
|
1035
|
+
taxRate: 0,
|
|
1036
|
+
whiteListed: null
|
|
1037
|
+
},
|
|
1038
|
+
GovtBondWL: {
|
|
1039
|
+
assetClass: "GovtBondWL",
|
|
1040
|
+
bucketGain: "A",
|
|
1041
|
+
bucketLoss: "B",
|
|
1042
|
+
taxRate: 0.125,
|
|
1043
|
+
whiteListed: true
|
|
1044
|
+
},
|
|
1045
|
+
GovtBondOther: {
|
|
1046
|
+
assetClass: "GovtBondOther",
|
|
1047
|
+
bucketGain: "A",
|
|
1048
|
+
bucketLoss: "B",
|
|
1049
|
+
taxRate: 0.26,
|
|
1050
|
+
whiteListed: false
|
|
1051
|
+
},
|
|
1052
|
+
CorpBond: {
|
|
1053
|
+
assetClass: "CorpBond",
|
|
1054
|
+
bucketGain: "B",
|
|
1055
|
+
bucketLoss: "B",
|
|
1056
|
+
taxRate: 0,
|
|
1057
|
+
whiteListed: null
|
|
1058
|
+
},
|
|
1059
|
+
Derivative: {
|
|
1060
|
+
assetClass: "Derivative",
|
|
1061
|
+
bucketGain: "B",
|
|
1062
|
+
bucketLoss: "B",
|
|
1063
|
+
taxRate: 0,
|
|
1064
|
+
whiteListed: null
|
|
1065
|
+
},
|
|
1066
|
+
LeverageCert: {
|
|
1067
|
+
assetClass: "LeverageCert",
|
|
1068
|
+
bucketGain: "B",
|
|
1069
|
+
bucketLoss: "B",
|
|
1070
|
+
taxRate: 0,
|
|
1071
|
+
whiteListed: null
|
|
1072
|
+
},
|
|
1073
|
+
CapProtectedCert: {
|
|
1074
|
+
assetClass: "CapProtectedCert",
|
|
1075
|
+
bucketGain: "A",
|
|
1076
|
+
bucketLoss: "B",
|
|
1077
|
+
taxRate: 0.26,
|
|
1078
|
+
whiteListed: null
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
function buildEntryFromAssetClass(assetClass, product) {
|
|
1082
|
+
const mapped = ASSET_CLASS_DEFAULTS[assetClass];
|
|
1083
|
+
return {
|
|
1084
|
+
product,
|
|
1085
|
+
assetClass: mapped.assetClass,
|
|
1086
|
+
bucketGain: mapped.bucketGain,
|
|
1087
|
+
bucketLoss: mapped.bucketLoss,
|
|
1088
|
+
taxRate: mapped.taxRate,
|
|
1089
|
+
whiteListed: mapped.whiteListed,
|
|
1090
|
+
confirmedByUser: true,
|
|
1091
|
+
source: "user"
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
742
1094
|
var Classifier = class {
|
|
743
1095
|
interactive;
|
|
744
1096
|
constructor(options) {
|
|
@@ -760,7 +1112,7 @@ var Classifier = class {
|
|
|
760
1112
|
}
|
|
761
1113
|
return parsed.classifications;
|
|
762
1114
|
}
|
|
763
|
-
async classify(transactions, sidecarPath, _httpPost = httpsPost) {
|
|
1115
|
+
async classify(transactions, sidecarPath, options, _httpPost = httpsPost) {
|
|
764
1116
|
const isinToProduct = /* @__PURE__ */ new Map();
|
|
765
1117
|
for (const tx of transactions) {
|
|
766
1118
|
if (tx.isin && !isinToProduct.has(tx.isin)) {
|
|
@@ -768,13 +1120,21 @@ var Classifier = class {
|
|
|
768
1120
|
}
|
|
769
1121
|
}
|
|
770
1122
|
const confirmed = {};
|
|
771
|
-
if (
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1123
|
+
if (sidecarPath !== void 0) {
|
|
1124
|
+
if (fs2.existsSync(sidecarPath)) {
|
|
1125
|
+
const existingMap = await this.load(sidecarPath);
|
|
1126
|
+
for (const [isin, entry] of Object.entries(existingMap)) {
|
|
1127
|
+
if (entry.confirmedByUser) {
|
|
1128
|
+
confirmed[isin] = entry;
|
|
1129
|
+
}
|
|
776
1130
|
}
|
|
777
1131
|
}
|
|
1132
|
+
} else if (options?.existingClassification) {
|
|
1133
|
+
Object.assign(confirmed, options.existingClassification);
|
|
1134
|
+
}
|
|
1135
|
+
for (const [isin, assetClass] of Object.entries(options?.overrides ?? {})) {
|
|
1136
|
+
const product = isinToProduct.get(isin) ?? isin;
|
|
1137
|
+
confirmed[isin] = buildEntryFromAssetClass(assetClass, product);
|
|
778
1138
|
}
|
|
779
1139
|
const toProcess = [];
|
|
780
1140
|
for (const isin of isinToProduct.keys()) {
|
|
@@ -784,76 +1144,100 @@ var Classifier = class {
|
|
|
784
1144
|
}
|
|
785
1145
|
const warnings = [];
|
|
786
1146
|
const newEntries = {};
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1147
|
+
if (options?.offline) {
|
|
1148
|
+
for (const isin of toProcess) {
|
|
1149
|
+
const product = isinToProduct.get(isin) ?? isin;
|
|
1150
|
+
warnings.push(
|
|
1151
|
+
`Unrecognized type for ${isin}: unknown. Please classify manually.`
|
|
1152
|
+
);
|
|
1153
|
+
newEntries[isin] = {
|
|
1154
|
+
product,
|
|
1155
|
+
assetClass: "Stock",
|
|
1156
|
+
bucketGain: "B",
|
|
1157
|
+
bucketLoss: "B",
|
|
1158
|
+
taxRate: 0,
|
|
1159
|
+
whiteListed: null,
|
|
1160
|
+
confirmedByUser: false,
|
|
1161
|
+
source: "user"
|
|
1162
|
+
};
|
|
791
1163
|
}
|
|
792
|
-
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
)
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1164
|
+
} else {
|
|
1165
|
+
const BATCH_SIZE = 10;
|
|
1166
|
+
const totalBatches = Math.ceil(toProcess.length / BATCH_SIZE);
|
|
1167
|
+
for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
|
|
1168
|
+
if (i > 0) {
|
|
1169
|
+
await new Promise((resolve) => setTimeout(resolve, 6e3));
|
|
1170
|
+
}
|
|
1171
|
+
const batch = toProcess.slice(i, i + BATCH_SIZE);
|
|
1172
|
+
const requestBody = JSON.stringify(
|
|
1173
|
+
batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
|
|
1174
|
+
);
|
|
1175
|
+
let response = null;
|
|
1176
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1177
|
+
try {
|
|
1178
|
+
const r = await _httpPost(
|
|
1179
|
+
"https://api.openfigi.com/v3/mapping",
|
|
1180
|
+
requestBody,
|
|
1181
|
+
1e4
|
|
1182
|
+
);
|
|
1183
|
+
if (r.status < 500) {
|
|
1184
|
+
response = r;
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1187
|
+
} catch {
|
|
1188
|
+
throw new ClassificationError("NETWORK_ERROR");
|
|
807
1189
|
}
|
|
808
|
-
}
|
|
1190
|
+
}
|
|
1191
|
+
if (response === null) {
|
|
809
1192
|
throw new ClassificationError("NETWORK_ERROR");
|
|
810
1193
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1194
|
+
const results = JSON.parse(response.data);
|
|
1195
|
+
for (let j = 0; j < batch.length; j++) {
|
|
1196
|
+
const isin = batch[j];
|
|
1197
|
+
const result = results[j];
|
|
1198
|
+
const product = isinToProduct.get(isin) ?? isin;
|
|
1199
|
+
if (!result || result.error || !result.data || result.data.length === 0) {
|
|
1200
|
+
warnings.push(
|
|
1201
|
+
`Unrecognized type for ${isin}: unknown. Please classify manually.`
|
|
1202
|
+
);
|
|
1203
|
+
newEntries[isin] = {
|
|
1204
|
+
product,
|
|
1205
|
+
assetClass: "Stock",
|
|
1206
|
+
bucketGain: "B",
|
|
1207
|
+
bucketLoss: "B",
|
|
1208
|
+
taxRate: 0,
|
|
1209
|
+
whiteListed: null,
|
|
1210
|
+
confirmedByUser: false,
|
|
1211
|
+
source: "user"
|
|
1212
|
+
};
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
const st = result.data[0]?.securityType;
|
|
1216
|
+
const st2 = result.data[0]?.securityType2;
|
|
1217
|
+
const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
|
|
1218
|
+
newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
|
|
835
1219
|
}
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
|
|
839
|
-
newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
|
|
1220
|
+
const batchIndex = i / BATCH_SIZE;
|
|
1221
|
+
options?.onBatchProgress?.(batchIndex + 1, totalBatches);
|
|
840
1222
|
}
|
|
841
1223
|
}
|
|
842
1224
|
const mergedMap = { ...newEntries, ...confirmed };
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1225
|
+
if (sidecarPath !== void 0) {
|
|
1226
|
+
const sidecarContent = JSON.stringify(
|
|
1227
|
+
{
|
|
1228
|
+
version: 1,
|
|
1229
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1230
|
+
classifications: mergedMap,
|
|
1231
|
+
warnings
|
|
1232
|
+
},
|
|
1233
|
+
null,
|
|
1234
|
+
2
|
|
1235
|
+
);
|
|
1236
|
+
try {
|
|
1237
|
+
fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
|
|
1238
|
+
} catch {
|
|
1239
|
+
throw new ClassificationError("WRITE_ERROR");
|
|
1240
|
+
}
|
|
857
1241
|
}
|
|
858
1242
|
return mergedMap;
|
|
859
1243
|
}
|