@gabrielerandelli/minus-tracker 0.7.0 → 0.8.1

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.
@@ -0,0 +1,1885 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/mcp/server.ts
4
+ import { fileURLToPath as fileURLToPath2 } from "url";
5
+ import * as path2 from "path";
6
+ import * as fs3 from "fs";
7
+ import { Ajv } from "ajv";
8
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
+ import {
10
+ ListToolsRequestSchema,
11
+ CallToolRequestSchema
12
+ } from "@modelcontextprotocol/sdk/types.js";
13
+
14
+ // src/mcp/schemas.generated.ts
15
+ var parseTransactionsInputSchema = {
16
+ "$schema": "http://json-schema.org/draft-07/schema#",
17
+ "type": "object",
18
+ "properties": {
19
+ "csv": {
20
+ "type": "string"
21
+ }
22
+ },
23
+ "required": [
24
+ "csv"
25
+ ],
26
+ "additionalProperties": false,
27
+ "definitions": {}
28
+ };
29
+ var classifyInstrumentsInputSchema = {
30
+ "$schema": "http://json-schema.org/draft-07/schema#",
31
+ "type": "object",
32
+ "properties": {
33
+ "transactions": {
34
+ "type": "array",
35
+ "items": {
36
+ "$ref": "#/definitions/Transaction"
37
+ }
38
+ },
39
+ "existingClassification": {
40
+ "$ref": "#/definitions/ClassificationMap"
41
+ },
42
+ "overrides": {
43
+ "type": "object",
44
+ "additionalProperties": {
45
+ "$ref": "#/definitions/AssetClass"
46
+ }
47
+ },
48
+ "offline": {
49
+ "type": "boolean"
50
+ }
51
+ },
52
+ "required": [
53
+ "transactions"
54
+ ],
55
+ "additionalProperties": false,
56
+ "definitions": {
57
+ "Transaction": {
58
+ "type": "object",
59
+ "properties": {
60
+ "isin": {
61
+ "type": "string"
62
+ },
63
+ "product": {
64
+ "type": "string"
65
+ },
66
+ "date": {
67
+ "type": "string"
68
+ },
69
+ "type": {
70
+ "type": "string",
71
+ "enum": [
72
+ "BUY",
73
+ "SELL"
74
+ ]
75
+ },
76
+ "quantity": {
77
+ "type": "number"
78
+ },
79
+ "pricePerUnit": {
80
+ "type": "number"
81
+ },
82
+ "currency": {
83
+ "type": "string"
84
+ },
85
+ "totalLocal": {
86
+ "type": "number"
87
+ },
88
+ "totalEUR": {
89
+ "type": "number"
90
+ },
91
+ "feesEUR": {
92
+ "type": "number"
93
+ },
94
+ "fxRate": {
95
+ "type": "number"
96
+ }
97
+ },
98
+ "required": [
99
+ "isin",
100
+ "product",
101
+ "date",
102
+ "type",
103
+ "quantity",
104
+ "pricePerUnit",
105
+ "currency",
106
+ "totalLocal",
107
+ "totalEUR",
108
+ "feesEUR"
109
+ ],
110
+ "additionalProperties": false
111
+ },
112
+ "ClassificationMap": {
113
+ "type": "object",
114
+ "additionalProperties": {
115
+ "$ref": "#/definitions/ClassificationEntry"
116
+ }
117
+ },
118
+ "ClassificationEntry": {
119
+ "type": "object",
120
+ "properties": {
121
+ "product": {
122
+ "type": "string"
123
+ },
124
+ "assetClass": {
125
+ "$ref": "#/definitions/AssetClass"
126
+ },
127
+ "bucketGain": {
128
+ "type": "string",
129
+ "enum": [
130
+ "A",
131
+ "B"
132
+ ]
133
+ },
134
+ "bucketLoss": {
135
+ "type": "string",
136
+ "enum": [
137
+ "A",
138
+ "B"
139
+ ]
140
+ },
141
+ "taxRate": {
142
+ "type": "number"
143
+ },
144
+ "whiteListed": {
145
+ "type": [
146
+ "boolean",
147
+ "null"
148
+ ]
149
+ },
150
+ "confirmedByUser": {
151
+ "type": "boolean"
152
+ },
153
+ "source": {
154
+ "type": "string",
155
+ "enum": [
156
+ "openfigi",
157
+ "user"
158
+ ]
159
+ }
160
+ },
161
+ "required": [
162
+ "product",
163
+ "assetClass",
164
+ "bucketGain",
165
+ "bucketLoss",
166
+ "taxRate",
167
+ "whiteListed",
168
+ "confirmedByUser",
169
+ "source"
170
+ ],
171
+ "additionalProperties": false
172
+ },
173
+ "AssetClass": {
174
+ "type": "string",
175
+ "enum": [
176
+ "ETF",
177
+ "Stock",
178
+ "ETC",
179
+ "GovtBondWL",
180
+ "GovtBondOther",
181
+ "CorpBond",
182
+ "Derivative",
183
+ "LeverageCert",
184
+ "CapProtectedCert"
185
+ ]
186
+ }
187
+ }
188
+ };
189
+ var calculateGainsInputSchema = {
190
+ "$schema": "http://json-schema.org/draft-07/schema#",
191
+ "type": "object",
192
+ "properties": {
193
+ "transactions": {
194
+ "type": "array",
195
+ "items": {
196
+ "$ref": "#/definitions/Transaction"
197
+ }
198
+ },
199
+ "method": {
200
+ "$ref": "#/definitions/LotMethod"
201
+ },
202
+ "parseWarnings": {
203
+ "type": "array",
204
+ "items": {
205
+ "type": "string"
206
+ }
207
+ },
208
+ "classification": {
209
+ "$ref": "#/definitions/ClassificationMap"
210
+ },
211
+ "carryForward": {
212
+ "type": "array",
213
+ "items": {
214
+ "$ref": "#/definitions/CarryForward"
215
+ }
216
+ },
217
+ "incomeRows": {
218
+ "type": "array",
219
+ "items": {
220
+ "$ref": "#/definitions/IncomeRow"
221
+ }
222
+ }
223
+ },
224
+ "required": [
225
+ "transactions",
226
+ "method"
227
+ ],
228
+ "additionalProperties": false,
229
+ "definitions": {
230
+ "Transaction": {
231
+ "type": "object",
232
+ "properties": {
233
+ "isin": {
234
+ "type": "string"
235
+ },
236
+ "product": {
237
+ "type": "string"
238
+ },
239
+ "date": {
240
+ "type": "string"
241
+ },
242
+ "type": {
243
+ "type": "string",
244
+ "enum": [
245
+ "BUY",
246
+ "SELL"
247
+ ]
248
+ },
249
+ "quantity": {
250
+ "type": "number"
251
+ },
252
+ "pricePerUnit": {
253
+ "type": "number"
254
+ },
255
+ "currency": {
256
+ "type": "string"
257
+ },
258
+ "totalLocal": {
259
+ "type": "number"
260
+ },
261
+ "totalEUR": {
262
+ "type": "number"
263
+ },
264
+ "feesEUR": {
265
+ "type": "number"
266
+ },
267
+ "fxRate": {
268
+ "type": "number"
269
+ }
270
+ },
271
+ "required": [
272
+ "isin",
273
+ "product",
274
+ "date",
275
+ "type",
276
+ "quantity",
277
+ "pricePerUnit",
278
+ "currency",
279
+ "totalLocal",
280
+ "totalEUR",
281
+ "feesEUR"
282
+ ],
283
+ "additionalProperties": false
284
+ },
285
+ "LotMethod": {
286
+ "type": "string",
287
+ "enum": [
288
+ "LIFO",
289
+ "FIFO"
290
+ ]
291
+ },
292
+ "ClassificationMap": {
293
+ "type": "object",
294
+ "additionalProperties": {
295
+ "$ref": "#/definitions/ClassificationEntry"
296
+ }
297
+ },
298
+ "ClassificationEntry": {
299
+ "type": "object",
300
+ "properties": {
301
+ "product": {
302
+ "type": "string"
303
+ },
304
+ "assetClass": {
305
+ "$ref": "#/definitions/AssetClass"
306
+ },
307
+ "bucketGain": {
308
+ "type": "string",
309
+ "enum": [
310
+ "A",
311
+ "B"
312
+ ]
313
+ },
314
+ "bucketLoss": {
315
+ "type": "string",
316
+ "enum": [
317
+ "A",
318
+ "B"
319
+ ]
320
+ },
321
+ "taxRate": {
322
+ "type": "number"
323
+ },
324
+ "whiteListed": {
325
+ "type": [
326
+ "boolean",
327
+ "null"
328
+ ]
329
+ },
330
+ "confirmedByUser": {
331
+ "type": "boolean"
332
+ },
333
+ "source": {
334
+ "type": "string",
335
+ "enum": [
336
+ "openfigi",
337
+ "user"
338
+ ]
339
+ }
340
+ },
341
+ "required": [
342
+ "product",
343
+ "assetClass",
344
+ "bucketGain",
345
+ "bucketLoss",
346
+ "taxRate",
347
+ "whiteListed",
348
+ "confirmedByUser",
349
+ "source"
350
+ ],
351
+ "additionalProperties": false
352
+ },
353
+ "AssetClass": {
354
+ "type": "string",
355
+ "enum": [
356
+ "ETF",
357
+ "Stock",
358
+ "ETC",
359
+ "GovtBondWL",
360
+ "GovtBondOther",
361
+ "CorpBond",
362
+ "Derivative",
363
+ "LeverageCert",
364
+ "CapProtectedCert"
365
+ ]
366
+ },
367
+ "CarryForward": {
368
+ "type": "object",
369
+ "properties": {
370
+ "year": {
371
+ "type": "number"
372
+ },
373
+ "amount": {
374
+ "type": "number"
375
+ }
376
+ },
377
+ "required": [
378
+ "year",
379
+ "amount"
380
+ ],
381
+ "additionalProperties": false
382
+ },
383
+ "IncomeRow": {
384
+ "type": "object",
385
+ "properties": {
386
+ "isin": {
387
+ "type": "string"
388
+ },
389
+ "product": {
390
+ "type": "string"
391
+ },
392
+ "date": {
393
+ "type": "string"
394
+ },
395
+ "incomeType": {
396
+ "type": "string",
397
+ "enum": [
398
+ "dividend",
399
+ "coupon"
400
+ ]
401
+ },
402
+ "grossAmount": {
403
+ "type": "number"
404
+ },
405
+ "withholdingTax": {
406
+ "type": "number"
407
+ },
408
+ "currency": {
409
+ "type": "string"
410
+ },
411
+ "fxRate": {
412
+ "type": "number"
413
+ }
414
+ },
415
+ "required": [
416
+ "isin",
417
+ "product",
418
+ "date",
419
+ "incomeType",
420
+ "grossAmount",
421
+ "withholdingTax",
422
+ "currency"
423
+ ],
424
+ "additionalProperties": false
425
+ }
426
+ }
427
+ };
428
+
429
+ // src/errors.ts
430
+ var ParseError = class extends Error {
431
+ code;
432
+ columnName;
433
+ constructor(code, columnName) {
434
+ const msg = code === "INVALID_CSV" ? "Invalid CSV: unable to parse" : `Missing required column: ${columnName}`;
435
+ super(msg);
436
+ this.name = "ParseError";
437
+ this.code = code;
438
+ this.columnName = columnName;
439
+ }
440
+ };
441
+ var CalculationError = class extends Error {
442
+ isin;
443
+ date;
444
+ constructor(isin, date) {
445
+ super(`No open lots for ISIN ${isin} on ${date}`);
446
+ this.name = "CalculationError";
447
+ this.isin = isin;
448
+ this.date = date;
449
+ }
450
+ };
451
+ var ClassificationError = class extends Error {
452
+ code;
453
+ constructor(code, message) {
454
+ super(
455
+ 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")
456
+ );
457
+ this.name = "ClassificationError";
458
+ this.code = code;
459
+ }
460
+ };
461
+
462
+ // src/rates/index.ts
463
+ import { fileURLToPath } from "url";
464
+ import * as path from "path";
465
+ import * as fs from "fs";
466
+ var _bundled = void 0;
467
+ function getBundledSnapshot() {
468
+ if (_bundled !== void 0) return _bundled;
469
+ const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
470
+ const bundledPath = path.join(__dirname2, "../data/ecb-rates.json");
471
+ try {
472
+ _bundled = JSON.parse(
473
+ fs.readFileSync(bundledPath, "utf8")
474
+ );
475
+ } catch {
476
+ _bundled = null;
477
+ }
478
+ return _bundled;
479
+ }
480
+ function getUserSnapshot() {
481
+ const platform = process.platform;
482
+ let configDir;
483
+ if (platform === "win32") {
484
+ configDir = process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? "~", "AppData", "Roaming");
485
+ } else {
486
+ configDir = process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? "~", ".config");
487
+ }
488
+ const userPath = path.join(configDir, "minus-tracker", "ecb-rates.json");
489
+ try {
490
+ return JSON.parse(fs.readFileSync(userPath, "utf8"));
491
+ } catch {
492
+ return null;
493
+ }
494
+ }
495
+ function getActiveSnapshot() {
496
+ const bundled = getBundledSnapshot();
497
+ const user = getUserSnapshot();
498
+ if (!bundled && !user) {
499
+ throw new Error(
500
+ "No ECB rates snapshot available. Run `minus-tracker rates --update` to fetch rates."
501
+ );
502
+ }
503
+ if (!bundled) return user;
504
+ if (!user) return bundled;
505
+ const merged = {};
506
+ for (const ccy of /* @__PURE__ */ new Set([...Object.keys(bundled), ...Object.keys(user)])) {
507
+ merged[ccy] = { ...bundled[ccy] ?? {}, ...user[ccy] ?? {} };
508
+ }
509
+ return merged;
510
+ }
511
+ function subtractDays(isoDate, days) {
512
+ const d = /* @__PURE__ */ new Date(isoDate + "T00:00:00Z");
513
+ d.setUTCDate(d.getUTCDate() - days);
514
+ return d.toISOString().slice(0, 10);
515
+ }
516
+ function lookupRate(currency, date, snapshot) {
517
+ if (currency === "EUR") return 1;
518
+ const s = snapshot ?? getActiveSnapshot();
519
+ const currencyRates = s[currency];
520
+ if (!currencyRates) return null;
521
+ for (let i = 0; i <= 3; i++) {
522
+ const d = i === 0 ? date : subtractDays(date, i);
523
+ if (currencyRates[d] !== void 0) {
524
+ return currencyRates[d];
525
+ }
526
+ }
527
+ return null;
528
+ }
529
+
530
+ // src/parser/warnings.ts
531
+ function warningToEnglish(w) {
532
+ switch (w.code) {
533
+ case "MISSING_ISIN":
534
+ return `Row ${w.row}: missing ISIN \u2014 skipped`;
535
+ case "UNSUPPORTED_CURRENCY":
536
+ return `Row ${w.row}: unsupported currency ${w.currency} \u2014 skipped`;
537
+ case "NO_ECB_RATE":
538
+ return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
539
+ case "QUANTITY_ZERO":
540
+ return `Row ${w.row}: quantity is 0 \u2014 skipped`;
541
+ case "MISSING_ISIN_INCOME":
542
+ return `Row ${w.row}: blank ISIN on income row \u2014 skipped`;
543
+ case "ORPHAN_WITHHOLDING":
544
+ return `Withholding row for ${w.isin} on ${w.date}: no matching income row \u2014 skipped`;
545
+ }
546
+ }
547
+
548
+ // src/parser/index.ts
549
+ var REQUIRED_COLUMNS = [
550
+ "ISIN",
551
+ "Quantity",
552
+ "Price",
553
+ "Date",
554
+ "Local value",
555
+ "Local value currency",
556
+ "Transaction costs",
557
+ "Transaction costs currency",
558
+ "Product"
559
+ ];
560
+ var INCOME_KEYWORDS = ["DIVIDEND", "COUPON", "INTEREST", "CEDOLA"];
561
+ var TAX_KEYWORDS = ["DIVIDEND TAX", "WITHHOLDING", "RITENUTA"];
562
+ function parseCSVRow(line) {
563
+ const fields = [];
564
+ let i = 0;
565
+ while (i <= line.length) {
566
+ if (i === line.length) break;
567
+ if (line[i] === '"') {
568
+ let field = "";
569
+ i++;
570
+ while (i < line.length) {
571
+ if (line[i] === '"' && i + 1 < line.length && line[i + 1] === '"') {
572
+ field += '"';
573
+ i += 2;
574
+ } else if (line[i] === '"') {
575
+ i++;
576
+ break;
577
+ } else {
578
+ field += line[i++];
579
+ }
580
+ }
581
+ fields.push(field);
582
+ if (i < line.length && line[i] === ",") i++;
583
+ } else {
584
+ const start = i;
585
+ while (i < line.length && line[i] !== ",") i++;
586
+ fields.push(line.slice(start, i));
587
+ if (i < line.length) i++;
588
+ }
589
+ }
590
+ return fields;
591
+ }
592
+ function parseCSV(input) {
593
+ const normalized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
594
+ return normalized.split("\n").map(parseCSVRow);
595
+ }
596
+ function parseDate(ddmmyyyy) {
597
+ const [dd, mm, yyyy] = ddmmyyyy.split("-");
598
+ return `${yyyy}-${mm}-${dd}`;
599
+ }
600
+ var DEGIROParser = class {
601
+ _warningEntries = [];
602
+ _snapshot;
603
+ _incomeRows = [];
604
+ constructor(snapshot) {
605
+ this._snapshot = snapshot;
606
+ }
607
+ /**
608
+ * Parse a DEGIRO Transactions CSV export string.
609
+ *
610
+ * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
611
+ * @returns Array of normalised Transaction objects. Empty if no data rows are found.
612
+ * @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
613
+ * @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
614
+ *
615
+ * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
616
+ * 3 trading days are skipped silently. Inspect `parser.warnings` for details.
617
+ */
618
+ parse(csv) {
619
+ this._warningEntries = [];
620
+ this._incomeRows = [];
621
+ if (typeof csv !== "string" || csv.includes("\0")) {
622
+ throw new ParseError("INVALID_CSV");
623
+ }
624
+ let rows;
625
+ try {
626
+ rows = parseCSV(csv);
627
+ } catch {
628
+ throw new ParseError("INVALID_CSV");
629
+ }
630
+ if (rows.length === 0) {
631
+ throw new ParseError("INVALID_CSV");
632
+ }
633
+ const header = rows[0];
634
+ const colIndex = {};
635
+ for (let i = 0; i < header.length; i++) {
636
+ colIndex[header[i].trim()] = i;
637
+ }
638
+ for (const col of REQUIRED_COLUMNS) {
639
+ if (colIndex[col] === void 0) {
640
+ throw new ParseError("MISSING_COLUMN", col);
641
+ }
642
+ }
643
+ const snapshot = this._snapshot ?? getActiveSnapshot();
644
+ const transactions = [];
645
+ const incomeCandidates = [];
646
+ const withholdingCandidates = [];
647
+ for (let r = 1; r < rows.length; r++) {
648
+ const row = rows[r];
649
+ const rowIndex = r + 1;
650
+ if (row.every((cell) => cell.trim() === "")) continue;
651
+ const get = (col) => (row[colIndex[col]] ?? "").trim();
652
+ const isin = get("ISIN");
653
+ const product = get("Product");
654
+ const qtyStr = get("Quantity");
655
+ const rawQty = parseFloat(qtyStr);
656
+ const qtyIsZeroOrBlank = qtyStr === "" || isNaN(rawQty) || rawQty === 0;
657
+ if (qtyIsZeroOrBlank) {
658
+ const upperProduct = product.toUpperCase();
659
+ const isTaxKeyword = TAX_KEYWORDS.some((k) => upperProduct.includes(k));
660
+ const isIncomeKeyword = !isTaxKeyword && INCOME_KEYWORDS.some((k) => upperProduct.includes(k));
661
+ if (isTaxKeyword || isIncomeKeyword) {
662
+ const rawLocalValue = parseFloat(get("Local value"));
663
+ const localValue = isNaN(rawLocalValue) ? 0 : rawLocalValue;
664
+ const isoDate2 = parseDate(get("Date"));
665
+ if (isTaxKeyword && localValue < 0) {
666
+ if (!isin) {
667
+ this._warningEntries.push({
668
+ code: "MISSING_ISIN_INCOME",
669
+ row: rowIndex
670
+ });
671
+ continue;
672
+ }
673
+ withholdingCandidates.push({
674
+ isin,
675
+ date: isoDate2,
676
+ amount: Math.abs(localValue),
677
+ row: rowIndex
678
+ });
679
+ continue;
680
+ }
681
+ if (isIncomeKeyword && localValue > 0) {
682
+ if (!isin) {
683
+ this._warningEntries.push({
684
+ code: "MISSING_ISIN_INCOME",
685
+ row: rowIndex
686
+ });
687
+ continue;
688
+ }
689
+ const incomeType = upperProduct.includes(
690
+ "DIVIDEND"
691
+ ) ? "dividend" : "coupon";
692
+ incomeCandidates.push({
693
+ isin,
694
+ product,
695
+ date: isoDate2,
696
+ incomeType,
697
+ localValue,
698
+ currency: get("Local value currency"),
699
+ row: rowIndex
700
+ });
701
+ continue;
702
+ }
703
+ }
704
+ }
705
+ if (!isin) {
706
+ this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
707
+ continue;
708
+ }
709
+ if (isNaN(rawQty) || rawQty === 0) {
710
+ this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
711
+ continue;
712
+ }
713
+ const type = rawQty > 0 ? "BUY" : "SELL";
714
+ const quantity = Math.abs(rawQty);
715
+ const isoDate = parseDate(get("Date"));
716
+ const currency = get("Local value currency");
717
+ const rawTotalLocal = parseFloat(get("Local value"));
718
+ const totalLocal = isNaN(rawTotalLocal) ? 0 : rawTotalLocal;
719
+ let totalEUR;
720
+ let fxRate;
721
+ if (currency === "EUR") {
722
+ totalEUR = Math.abs(totalLocal);
723
+ fxRate = void 0;
724
+ } else {
725
+ const rate = lookupRate(currency, isoDate, snapshot);
726
+ if (rate === null) {
727
+ if (snapshot[currency] === void 0) {
728
+ this._warningEntries.push({
729
+ code: "UNSUPPORTED_CURRENCY",
730
+ row: rowIndex,
731
+ currency
732
+ });
733
+ } else {
734
+ this._warningEntries.push({
735
+ code: "NO_ECB_RATE",
736
+ row: rowIndex,
737
+ currency,
738
+ date: isoDate
739
+ });
740
+ }
741
+ continue;
742
+ }
743
+ totalEUR = Math.abs(totalLocal) / rate;
744
+ fxRate = rate;
745
+ }
746
+ const rawFees = parseFloat(get("Transaction costs"));
747
+ const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
748
+ const pricePerUnit = parseFloat(get("Price"));
749
+ transactions.push({
750
+ isin,
751
+ product,
752
+ date: isoDate,
753
+ type,
754
+ quantity,
755
+ pricePerUnit,
756
+ currency,
757
+ totalLocal,
758
+ totalEUR,
759
+ feesEUR,
760
+ fxRate
761
+ });
762
+ }
763
+ const withholdingByKey = /* @__PURE__ */ new Map();
764
+ for (const w of withholdingCandidates) {
765
+ const key = `${w.isin}|${w.date}`;
766
+ const existing = withholdingByKey.get(key);
767
+ withholdingByKey.set(key, {
768
+ isin: w.isin,
769
+ date: w.date,
770
+ amount: (existing?.amount ?? 0) + w.amount
771
+ });
772
+ }
773
+ const consumedKeys = /* @__PURE__ */ new Set();
774
+ const incomeRows = [];
775
+ for (const c of incomeCandidates) {
776
+ const key = `${c.isin}|${c.date}`;
777
+ const matchedWithholding = withholdingByKey.get(key);
778
+ let grossAmount;
779
+ let withholdingTax;
780
+ let fxRate;
781
+ if (c.currency === "EUR") {
782
+ grossAmount = Math.abs(c.localValue);
783
+ withholdingTax = matchedWithholding?.amount ?? 0;
784
+ fxRate = void 0;
785
+ } else {
786
+ const rate = lookupRate(c.currency, c.date, snapshot);
787
+ if (rate === null) {
788
+ if (snapshot[c.currency] === void 0) {
789
+ this._warningEntries.push({
790
+ code: "UNSUPPORTED_CURRENCY",
791
+ row: c.row,
792
+ currency: c.currency
793
+ });
794
+ } else {
795
+ this._warningEntries.push({
796
+ code: "NO_ECB_RATE",
797
+ row: c.row,
798
+ currency: c.currency,
799
+ date: c.date
800
+ });
801
+ }
802
+ continue;
803
+ }
804
+ grossAmount = Math.abs(c.localValue) / rate;
805
+ withholdingTax = matchedWithholding ? matchedWithholding.amount / rate : 0;
806
+ fxRate = rate;
807
+ }
808
+ if (matchedWithholding) consumedKeys.add(key);
809
+ incomeRows.push({
810
+ isin: c.isin,
811
+ product: c.product,
812
+ date: c.date,
813
+ incomeType: c.incomeType,
814
+ grossAmount,
815
+ withholdingTax,
816
+ currency: c.currency,
817
+ fxRate
818
+ });
819
+ }
820
+ for (const [key, w] of withholdingByKey) {
821
+ if (!consumedKeys.has(key)) {
822
+ this._warningEntries.push({
823
+ code: "ORPHAN_WITHHOLDING",
824
+ isin: w.isin,
825
+ date: w.date
826
+ });
827
+ }
828
+ }
829
+ this._incomeRows = incomeRows;
830
+ return transactions;
831
+ }
832
+ get warnings() {
833
+ return this._warningEntries.map(warningToEnglish);
834
+ }
835
+ get warningEntries() {
836
+ return this._warningEntries;
837
+ }
838
+ get incomeRows() {
839
+ return this._incomeRows;
840
+ }
841
+ };
842
+
843
+ // src/mcp/errors.ts
844
+ function toErrorResult(fields) {
845
+ return {
846
+ isError: true,
847
+ content: [{ type: "text", text: JSON.stringify(fields) }]
848
+ };
849
+ }
850
+ function toParseErrorResult(err) {
851
+ return toErrorResult({
852
+ code: err.code,
853
+ columnName: err.columnName,
854
+ message: err.message
855
+ });
856
+ }
857
+ function toClassificationErrorResult(err) {
858
+ return toErrorResult({
859
+ code: err.code,
860
+ message: err.message
861
+ });
862
+ }
863
+ function toCalculationErrorResult(err) {
864
+ return toErrorResult({
865
+ code: "CALCULATION_ERROR",
866
+ isin: err.isin,
867
+ date: err.date,
868
+ message: err.message
869
+ });
870
+ }
871
+
872
+ // src/mcp/tools/parse-transactions.ts
873
+ async function handleParseTransactions(args) {
874
+ const parser = new DEGIROParser();
875
+ try {
876
+ const transactions = parser.parse(args.csv);
877
+ return {
878
+ content: [
879
+ {
880
+ type: "text",
881
+ text: JSON.stringify({
882
+ transactions,
883
+ warnings: parser.warnings,
884
+ incomeRows: parser.incomeRows
885
+ })
886
+ }
887
+ ]
888
+ };
889
+ } catch (err) {
890
+ if (err instanceof ParseError) return toParseErrorResult(err);
891
+ throw err;
892
+ }
893
+ }
894
+
895
+ // src/classifier/index.ts
896
+ import * as fs2 from "fs";
897
+ import * as https from "https";
898
+ function httpsPost(url, body, timeoutMs) {
899
+ return new Promise((resolve, reject) => {
900
+ const u = new URL(url);
901
+ const req = https.request(
902
+ {
903
+ hostname: u.hostname,
904
+ path: u.pathname + u.search,
905
+ method: "POST",
906
+ headers: {
907
+ "Content-Type": "application/json",
908
+ "Content-Length": Buffer.byteLength(body)
909
+ }
910
+ },
911
+ (res) => {
912
+ let data = "";
913
+ res.on("data", (chunk) => {
914
+ data += chunk;
915
+ });
916
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, data }));
917
+ }
918
+ );
919
+ req.setTimeout(timeoutMs, () => {
920
+ req.destroy();
921
+ reject(new Error("timeout"));
922
+ });
923
+ req.on("error", reject);
924
+ req.write(body);
925
+ req.end();
926
+ });
927
+ }
928
+ var GOVT_BOND_WHITELIST = /* @__PURE__ */ new Set([
929
+ "IT",
930
+ "DE",
931
+ "FR",
932
+ "AT",
933
+ "BE",
934
+ "NL",
935
+ "ES",
936
+ "PT",
937
+ "FI",
938
+ "IE",
939
+ "LU",
940
+ "GR",
941
+ "SK",
942
+ "SI",
943
+ "LT",
944
+ "LV",
945
+ "EE",
946
+ "MT",
947
+ "CY",
948
+ "US",
949
+ "GB",
950
+ "CH",
951
+ "NO",
952
+ "SE",
953
+ "DK",
954
+ "JP",
955
+ "CA",
956
+ "AU",
957
+ "NZ",
958
+ "SG",
959
+ "HK",
960
+ "KR"
961
+ ]);
962
+ var SECURITY_TYPE_MAP = {
963
+ ETP: {
964
+ assetClass: "ETF",
965
+ bucketGain: "A",
966
+ bucketLoss: "B",
967
+ taxRate: 0.26,
968
+ whiteListed: null
969
+ },
970
+ ETF: {
971
+ assetClass: "ETF",
972
+ bucketGain: "A",
973
+ bucketLoss: "B",
974
+ taxRate: 0.26,
975
+ whiteListed: null
976
+ },
977
+ "Mutual Fund": {
978
+ assetClass: "ETF",
979
+ bucketGain: "A",
980
+ bucketLoss: "B",
981
+ taxRate: 0.26,
982
+ whiteListed: null
983
+ },
984
+ "Open-End Fund": {
985
+ assetClass: "ETF",
986
+ bucketGain: "A",
987
+ bucketLoss: "B",
988
+ taxRate: 0.26,
989
+ whiteListed: null
990
+ },
991
+ "Common Stock": {
992
+ assetClass: "Stock",
993
+ bucketGain: "B",
994
+ bucketLoss: "B",
995
+ taxRate: 0,
996
+ whiteListed: null
997
+ },
998
+ Equity: {
999
+ assetClass: "Stock",
1000
+ bucketGain: "B",
1001
+ bucketLoss: "B",
1002
+ taxRate: 0,
1003
+ whiteListed: null
1004
+ },
1005
+ "Exchange Traded Commodity": {
1006
+ assetClass: "ETC",
1007
+ bucketGain: "B",
1008
+ bucketLoss: "B",
1009
+ taxRate: 0,
1010
+ whiteListed: null
1011
+ },
1012
+ "Corporate Bond": {
1013
+ assetClass: "CorpBond",
1014
+ bucketGain: "B",
1015
+ bucketLoss: "B",
1016
+ taxRate: 0,
1017
+ whiteListed: null
1018
+ },
1019
+ Option: {
1020
+ assetClass: "Derivative",
1021
+ bucketGain: "B",
1022
+ bucketLoss: "B",
1023
+ taxRate: 0,
1024
+ whiteListed: null
1025
+ },
1026
+ Future: {
1027
+ assetClass: "Derivative",
1028
+ bucketGain: "B",
1029
+ bucketLoss: "B",
1030
+ taxRate: 0,
1031
+ whiteListed: null
1032
+ },
1033
+ Warrant: {
1034
+ assetClass: "Derivative",
1035
+ bucketGain: "B",
1036
+ bucketLoss: "B",
1037
+ taxRate: 0,
1038
+ whiteListed: null
1039
+ },
1040
+ CFD: {
1041
+ assetClass: "Derivative",
1042
+ bucketGain: "B",
1043
+ bucketLoss: "B",
1044
+ taxRate: 0,
1045
+ whiteListed: null
1046
+ },
1047
+ Swap: {
1048
+ assetClass: "Derivative",
1049
+ bucketGain: "B",
1050
+ bucketLoss: "B",
1051
+ taxRate: 0,
1052
+ whiteListed: null
1053
+ },
1054
+ "Leverage Certificate": {
1055
+ assetClass: "LeverageCert",
1056
+ bucketGain: "B",
1057
+ bucketLoss: "B",
1058
+ taxRate: 0,
1059
+ whiteListed: null
1060
+ },
1061
+ "Turbo Certificate": {
1062
+ assetClass: "LeverageCert",
1063
+ bucketGain: "B",
1064
+ bucketLoss: "B",
1065
+ taxRate: 0,
1066
+ whiteListed: null
1067
+ },
1068
+ "Capital Protected Certificate": {
1069
+ assetClass: "CapProtectedCert",
1070
+ bucketGain: "A",
1071
+ bucketLoss: "B",
1072
+ taxRate: 0.26,
1073
+ whiteListed: null
1074
+ }
1075
+ };
1076
+ function isKnownType(t) {
1077
+ return t === "Government Bond" || t in SECURITY_TYPE_MAP;
1078
+ }
1079
+ function classifyByType(isin, securityType, product, warnings) {
1080
+ if (securityType === "Government Bond") {
1081
+ const prefix = isin.slice(0, 2).toUpperCase();
1082
+ const whiteListed = GOVT_BOND_WHITELIST.has(prefix);
1083
+ return {
1084
+ product,
1085
+ assetClass: whiteListed ? "GovtBondWL" : "GovtBondOther",
1086
+ bucketGain: "A",
1087
+ bucketLoss: "B",
1088
+ taxRate: whiteListed ? 0.125 : 0.26,
1089
+ whiteListed,
1090
+ confirmedByUser: false,
1091
+ source: "openfigi"
1092
+ };
1093
+ }
1094
+ const mapped = SECURITY_TYPE_MAP[securityType];
1095
+ if (mapped) {
1096
+ return {
1097
+ product,
1098
+ assetClass: mapped.assetClass,
1099
+ bucketGain: mapped.bucketGain,
1100
+ bucketLoss: mapped.bucketLoss,
1101
+ taxRate: mapped.taxRate,
1102
+ whiteListed: mapped.whiteListed,
1103
+ confirmedByUser: false,
1104
+ source: "openfigi"
1105
+ };
1106
+ }
1107
+ warnings.push(
1108
+ `Unrecognized type for ${isin}: ${securityType}. Please classify manually.`
1109
+ );
1110
+ return {
1111
+ product,
1112
+ assetClass: "Stock",
1113
+ bucketGain: "B",
1114
+ bucketLoss: "B",
1115
+ taxRate: 0,
1116
+ whiteListed: null,
1117
+ confirmedByUser: false,
1118
+ source: "user"
1119
+ };
1120
+ }
1121
+ var ASSET_CLASS_DEFAULTS = {
1122
+ ETF: {
1123
+ assetClass: "ETF",
1124
+ bucketGain: "A",
1125
+ bucketLoss: "B",
1126
+ taxRate: 0.26,
1127
+ whiteListed: null
1128
+ },
1129
+ Stock: {
1130
+ assetClass: "Stock",
1131
+ bucketGain: "B",
1132
+ bucketLoss: "B",
1133
+ taxRate: 0,
1134
+ whiteListed: null
1135
+ },
1136
+ ETC: {
1137
+ assetClass: "ETC",
1138
+ bucketGain: "B",
1139
+ bucketLoss: "B",
1140
+ taxRate: 0,
1141
+ whiteListed: null
1142
+ },
1143
+ GovtBondWL: {
1144
+ assetClass: "GovtBondWL",
1145
+ bucketGain: "A",
1146
+ bucketLoss: "B",
1147
+ taxRate: 0.125,
1148
+ whiteListed: true
1149
+ },
1150
+ GovtBondOther: {
1151
+ assetClass: "GovtBondOther",
1152
+ bucketGain: "A",
1153
+ bucketLoss: "B",
1154
+ taxRate: 0.26,
1155
+ whiteListed: false
1156
+ },
1157
+ CorpBond: {
1158
+ assetClass: "CorpBond",
1159
+ bucketGain: "B",
1160
+ bucketLoss: "B",
1161
+ taxRate: 0,
1162
+ whiteListed: null
1163
+ },
1164
+ Derivative: {
1165
+ assetClass: "Derivative",
1166
+ bucketGain: "B",
1167
+ bucketLoss: "B",
1168
+ taxRate: 0,
1169
+ whiteListed: null
1170
+ },
1171
+ LeverageCert: {
1172
+ assetClass: "LeverageCert",
1173
+ bucketGain: "B",
1174
+ bucketLoss: "B",
1175
+ taxRate: 0,
1176
+ whiteListed: null
1177
+ },
1178
+ CapProtectedCert: {
1179
+ assetClass: "CapProtectedCert",
1180
+ bucketGain: "A",
1181
+ bucketLoss: "B",
1182
+ taxRate: 0.26,
1183
+ whiteListed: null
1184
+ }
1185
+ };
1186
+ function buildEntryFromAssetClass(assetClass, product) {
1187
+ const mapped = ASSET_CLASS_DEFAULTS[assetClass];
1188
+ return {
1189
+ product,
1190
+ assetClass: mapped.assetClass,
1191
+ bucketGain: mapped.bucketGain,
1192
+ bucketLoss: mapped.bucketLoss,
1193
+ taxRate: mapped.taxRate,
1194
+ whiteListed: mapped.whiteListed,
1195
+ confirmedByUser: true,
1196
+ source: "user"
1197
+ };
1198
+ }
1199
+ var Classifier = class {
1200
+ interactive;
1201
+ constructor(options) {
1202
+ this.interactive = options?.interactive ?? true;
1203
+ }
1204
+ async load(sidecarPath) {
1205
+ if (!fs2.existsSync(sidecarPath)) {
1206
+ throw new ClassificationError("SIDECAR_NOT_FOUND");
1207
+ }
1208
+ let parsed;
1209
+ try {
1210
+ const raw = fs2.readFileSync(sidecarPath, "utf-8");
1211
+ parsed = JSON.parse(raw);
1212
+ } catch {
1213
+ throw new ClassificationError("SIDECAR_MALFORMED");
1214
+ }
1215
+ if (typeof parsed !== "object" || parsed === null || parsed["version"] !== 1) {
1216
+ throw new ClassificationError("SIDECAR_VERSION");
1217
+ }
1218
+ return parsed.classifications;
1219
+ }
1220
+ async classify(transactions, sidecarPath, options, _httpPost = httpsPost) {
1221
+ const isinToProduct = /* @__PURE__ */ new Map();
1222
+ for (const tx of transactions) {
1223
+ if (tx.isin && !isinToProduct.has(tx.isin)) {
1224
+ isinToProduct.set(tx.isin, tx.product);
1225
+ }
1226
+ }
1227
+ const confirmed = {};
1228
+ if (sidecarPath !== void 0) {
1229
+ if (fs2.existsSync(sidecarPath)) {
1230
+ const existingMap = await this.load(sidecarPath);
1231
+ for (const [isin, entry] of Object.entries(existingMap)) {
1232
+ if (entry.confirmedByUser) {
1233
+ confirmed[isin] = entry;
1234
+ }
1235
+ }
1236
+ }
1237
+ } else if (options?.existingClassification) {
1238
+ Object.assign(confirmed, options.existingClassification);
1239
+ }
1240
+ for (const [isin, assetClass] of Object.entries(options?.overrides ?? {})) {
1241
+ const product = isinToProduct.get(isin) ?? isin;
1242
+ confirmed[isin] = buildEntryFromAssetClass(assetClass, product);
1243
+ }
1244
+ const toProcess = [];
1245
+ for (const isin of isinToProduct.keys()) {
1246
+ if (!(isin in confirmed)) {
1247
+ toProcess.push(isin);
1248
+ }
1249
+ }
1250
+ const warnings = [];
1251
+ const newEntries = {};
1252
+ if (options?.offline) {
1253
+ for (const isin of toProcess) {
1254
+ const product = isinToProduct.get(isin) ?? isin;
1255
+ warnings.push(
1256
+ `Unrecognized type for ${isin}: unknown. Please classify manually.`
1257
+ );
1258
+ newEntries[isin] = {
1259
+ product,
1260
+ assetClass: "Stock",
1261
+ bucketGain: "B",
1262
+ bucketLoss: "B",
1263
+ taxRate: 0,
1264
+ whiteListed: null,
1265
+ confirmedByUser: false,
1266
+ source: "user"
1267
+ };
1268
+ }
1269
+ } else {
1270
+ const BATCH_SIZE = 10;
1271
+ const totalBatches = Math.ceil(toProcess.length / BATCH_SIZE);
1272
+ for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
1273
+ if (i > 0) {
1274
+ await new Promise((resolve) => setTimeout(resolve, 6e3));
1275
+ }
1276
+ const batch = toProcess.slice(i, i + BATCH_SIZE);
1277
+ const requestBody = JSON.stringify(
1278
+ batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
1279
+ );
1280
+ let response = null;
1281
+ for (let attempt = 0; attempt < 2; attempt++) {
1282
+ try {
1283
+ const r = await _httpPost(
1284
+ "https://api.openfigi.com/v3/mapping",
1285
+ requestBody,
1286
+ 1e4
1287
+ );
1288
+ if (r.status < 500) {
1289
+ response = r;
1290
+ break;
1291
+ }
1292
+ } catch {
1293
+ throw new ClassificationError("NETWORK_ERROR");
1294
+ }
1295
+ }
1296
+ if (response === null) {
1297
+ throw new ClassificationError("NETWORK_ERROR");
1298
+ }
1299
+ const results = JSON.parse(response.data);
1300
+ for (let j = 0; j < batch.length; j++) {
1301
+ const isin = batch[j];
1302
+ const result = results[j];
1303
+ const product = isinToProduct.get(isin) ?? isin;
1304
+ if (!result || result.error || !result.data || result.data.length === 0) {
1305
+ warnings.push(
1306
+ `Unrecognized type for ${isin}: unknown. Please classify manually.`
1307
+ );
1308
+ newEntries[isin] = {
1309
+ product,
1310
+ assetClass: "Stock",
1311
+ bucketGain: "B",
1312
+ bucketLoss: "B",
1313
+ taxRate: 0,
1314
+ whiteListed: null,
1315
+ confirmedByUser: false,
1316
+ source: "user"
1317
+ };
1318
+ continue;
1319
+ }
1320
+ const st = result.data[0]?.securityType;
1321
+ const st2 = result.data[0]?.securityType2;
1322
+ const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
1323
+ newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
1324
+ }
1325
+ const batchIndex = i / BATCH_SIZE;
1326
+ options?.onBatchProgress?.(batchIndex + 1, totalBatches);
1327
+ }
1328
+ }
1329
+ const mergedMap = { ...newEntries, ...confirmed };
1330
+ if (sidecarPath !== void 0) {
1331
+ const sidecarContent = JSON.stringify(
1332
+ {
1333
+ version: 1,
1334
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1335
+ classifications: mergedMap,
1336
+ warnings
1337
+ },
1338
+ null,
1339
+ 2
1340
+ );
1341
+ try {
1342
+ fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
1343
+ } catch {
1344
+ throw new ClassificationError("WRITE_ERROR");
1345
+ }
1346
+ }
1347
+ return mergedMap;
1348
+ }
1349
+ };
1350
+
1351
+ // src/mcp/tools/classify-instruments.ts
1352
+ function buildUnresolvedWarnings(classification) {
1353
+ const warnings = [];
1354
+ for (const [isin, entry] of Object.entries(classification)) {
1355
+ if (!entry.confirmedByUser && entry.source === "user") {
1356
+ warnings.push(
1357
+ `Unrecognized type for ${isin}: unknown. Please classify manually.`
1358
+ );
1359
+ }
1360
+ }
1361
+ return warnings;
1362
+ }
1363
+ async function handleClassifyInstruments(args, extra, _httpPost) {
1364
+ const progressToken = extra?._meta?.progressToken;
1365
+ const sendNotification = extra?.sendNotification;
1366
+ const onBatchProgress = progressToken !== void 0 && sendNotification ? (done, total) => {
1367
+ void sendNotification({
1368
+ method: "notifications/progress",
1369
+ params: { progressToken, progress: done, total }
1370
+ });
1371
+ } : void 0;
1372
+ const classifier = new Classifier({ interactive: false });
1373
+ try {
1374
+ const classification = await classifier.classify(
1375
+ args.transactions,
1376
+ void 0,
1377
+ {
1378
+ existingClassification: args.existingClassification,
1379
+ overrides: args.overrides,
1380
+ offline: args.offline,
1381
+ onBatchProgress
1382
+ },
1383
+ _httpPost
1384
+ );
1385
+ return {
1386
+ content: [
1387
+ {
1388
+ type: "text",
1389
+ text: JSON.stringify({
1390
+ classification,
1391
+ warnings: buildUnresolvedWarnings(classification)
1392
+ })
1393
+ }
1394
+ ]
1395
+ };
1396
+ } catch (err) {
1397
+ if (err instanceof ClassificationError)
1398
+ return toClassificationErrorResult(err);
1399
+ throw err;
1400
+ }
1401
+ }
1402
+
1403
+ // src/dichiarazione/engine.ts
1404
+ import { writeFile } from "fs/promises";
1405
+ function roundHalfUp(x) {
1406
+ return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
1407
+ }
1408
+ function buildQuadroRT(bucketB, carryForward, taxYear) {
1409
+ const plusvalenze = bucketB.plusvalenze;
1410
+ const minusvalenze = bucketB.minusvalenze;
1411
+ const differenza = roundHalfUp(plusvalenze - minusvalenze);
1412
+ if (differenza > 0) {
1413
+ const sorted = [...carryForward].sort((a, b) => a.year - b.year);
1414
+ let remaining = differenza;
1415
+ const carryForwardApplied = [];
1416
+ for (const entry of sorted) {
1417
+ if (taxYear - entry.year > 4) continue;
1418
+ const consumed = Math.min(entry.amount, remaining);
1419
+ if (consumed > 0) {
1420
+ carryForwardApplied.push({
1421
+ annoOrigine: entry.year,
1422
+ importo: roundHalfUp(consumed)
1423
+ });
1424
+ remaining -= consumed;
1425
+ }
1426
+ }
1427
+ const imponibileNetto = roundHalfUp(remaining);
1428
+ const imposta = roundHalfUp(imponibileNetto * 0.26);
1429
+ return {
1430
+ plusvalenze,
1431
+ minusvalenze,
1432
+ differenza,
1433
+ carryForwardApplied,
1434
+ imponibileNetto,
1435
+ imposta,
1436
+ carryForwardRiportato: []
1437
+ };
1438
+ } else if (differenza < 0) {
1439
+ return {
1440
+ plusvalenze,
1441
+ minusvalenze,
1442
+ differenza,
1443
+ carryForwardApplied: [],
1444
+ imponibileNetto: 0,
1445
+ imposta: 0,
1446
+ carryForwardRiportato: [
1447
+ { annoOrigine: taxYear, importo: roundHalfUp(Math.abs(differenza)) }
1448
+ ]
1449
+ };
1450
+ } else {
1451
+ return {
1452
+ plusvalenze,
1453
+ minusvalenze,
1454
+ differenza: 0,
1455
+ carryForwardApplied: [],
1456
+ imponibileNetto: 0,
1457
+ imposta: 0,
1458
+ carryForwardRiportato: []
1459
+ };
1460
+ }
1461
+ }
1462
+ function buildQuadroRM(bucketA, incomeRows, taxYear) {
1463
+ const groups = bucketA?.groups ?? [];
1464
+ const group26 = groups.find((g) => g.taxRate === 0.26);
1465
+ const group125 = groups.find((g) => g.taxRate === 0.125);
1466
+ const capitaleAliquota26 = {
1467
+ plusvalenze: group26?.plusvalenze ?? 0,
1468
+ imposta: group26?.imposta ?? 0
1469
+ };
1470
+ const capitaleAliquota125 = {
1471
+ plusvalenze: group125?.plusvalenze ?? 0,
1472
+ imposta: group125?.imposta ?? 0
1473
+ };
1474
+ const dividendiEsteri = incomeRows.filter((r) => r.incomeType === "dividend").map((r) => ({
1475
+ isin: r.isin,
1476
+ prodotto: r.product,
1477
+ lordo: r.grossAmount,
1478
+ rittenutaEstera: r.withholdingTax
1479
+ }));
1480
+ const cedole = incomeRows.filter((r) => r.incomeType === "coupon").map((r) => ({
1481
+ isin: r.isin,
1482
+ prodotto: r.product,
1483
+ importo: r.grossAmount,
1484
+ rittenutaEstera: r.withholdingTax
1485
+ }));
1486
+ return {
1487
+ capitaleAliquota26,
1488
+ capitaleAliquota125,
1489
+ dividendiEsteri,
1490
+ cedole
1491
+ };
1492
+ }
1493
+ function buildDichiarazioneReport(quadroRT, quadroRM, taxYear) {
1494
+ const version = 1;
1495
+ const modello = "Redditi PF";
1496
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
1497
+ return {
1498
+ version,
1499
+ annoImposta: taxYear,
1500
+ modello,
1501
+ generatedAt,
1502
+ quadroRT,
1503
+ quadroRM,
1504
+ exportTo: async (path3) => {
1505
+ await writeFile(
1506
+ path3,
1507
+ JSON.stringify(
1508
+ {
1509
+ version,
1510
+ annoImposta: taxYear,
1511
+ modello,
1512
+ generatedAt,
1513
+ quadroRT,
1514
+ quadroRM
1515
+ },
1516
+ null,
1517
+ 2
1518
+ )
1519
+ );
1520
+ }
1521
+ };
1522
+ }
1523
+
1524
+ // src/calculator/index.ts
1525
+ function roundHalfUp2(x) {
1526
+ return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
1527
+ }
1528
+ function inferTaxYear(transactions) {
1529
+ const counts = {};
1530
+ for (const t of transactions) {
1531
+ const y = t.date.slice(0, 4);
1532
+ counts[y] = (counts[y] ?? 0) + 1;
1533
+ }
1534
+ const years = Object.keys(counts);
1535
+ if (years.length === 0)
1536
+ return { year: (/* @__PURE__ */ new Date()).getFullYear(), multipleYears: false };
1537
+ const year = parseInt(
1538
+ years.reduce((a, b) => counts[a] >= counts[b] ? a : b)
1539
+ );
1540
+ return { year, multipleYears: years.length > 1 };
1541
+ }
1542
+ var Calculator = class {
1543
+ _transactions;
1544
+ _parseWarnings;
1545
+ _options;
1546
+ constructor(transactions, parseWarnings, options) {
1547
+ this._transactions = transactions;
1548
+ this._parseWarnings = parseWarnings ?? [];
1549
+ this._options = options ?? {};
1550
+ }
1551
+ /**
1552
+ * Run LIFO or FIFO lot-matching over the transaction list.
1553
+ *
1554
+ * @param method - `"LIFO"` or `"FIFO"`.
1555
+ * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
1556
+ * ECB rates used, and any accumulated warnings.
1557
+ * @throws {CalculationError} when a SELL has no matching open buy lots.
1558
+ * `error.isin` and `error.date` identify the problematic transaction.
1559
+ */
1560
+ calculateGains(method) {
1561
+ const warnings = [...this._parseWarnings];
1562
+ const sorted = [...this._transactions].sort((a, b) => {
1563
+ if (a.date < b.date) return -1;
1564
+ if (a.date > b.date) return 1;
1565
+ if (a.type === "BUY" && b.type === "SELL") return -1;
1566
+ if (a.type === "SELL" && b.type === "BUY") return 1;
1567
+ return 0;
1568
+ });
1569
+ const { year: taxYear, multipleYears } = inferTaxYear(sorted);
1570
+ if (multipleYears) {
1571
+ warnings.push(
1572
+ "CSV contains transactions from multiple years \u2014 filter to a single year for accurate reporting."
1573
+ );
1574
+ }
1575
+ const openLots = /* @__PURE__ */ new Map();
1576
+ const matchedLots = [];
1577
+ const ratesUsed = {};
1578
+ for (const tx of sorted) {
1579
+ if (tx.fxRate !== void 0) {
1580
+ ratesUsed[`${tx.currency}:${tx.date}`] = tx.fxRate;
1581
+ }
1582
+ if (tx.type === "BUY") {
1583
+ const lot = {
1584
+ date: tx.date,
1585
+ quantity: tx.quantity,
1586
+ pricePerUnitEUR: tx.totalEUR / tx.quantity,
1587
+ feesEUR: tx.feesEUR,
1588
+ originalQty: tx.quantity,
1589
+ fxRate: tx.fxRate
1590
+ };
1591
+ if (!openLots.has(tx.isin)) openLots.set(tx.isin, []);
1592
+ openLots.get(tx.isin).push(lot);
1593
+ } else {
1594
+ const lots = openLots.get(tx.isin);
1595
+ if (!lots || lots.length === 0) {
1596
+ throw new CalculationError(tx.isin, tx.date);
1597
+ }
1598
+ const sellPricePerUnitEUR = tx.totalEUR / tx.quantity;
1599
+ let remainingSellQty = tx.quantity;
1600
+ while (remainingSellQty > 0) {
1601
+ if (!lots || lots.length === 0) {
1602
+ throw new CalculationError(tx.isin, tx.date);
1603
+ }
1604
+ const lot = method === "LIFO" ? lots[lots.length - 1] : lots[0];
1605
+ const matchedQty = Math.min(lot.quantity, remainingSellQty);
1606
+ const allocatedBuyFeesEUR = lot.feesEUR * (matchedQty / lot.originalQty);
1607
+ const allocatedSellFeesEUR = tx.feesEUR * (matchedQty / tx.quantity);
1608
+ const buyCostEUR = lot.pricePerUnitEUR * matchedQty + allocatedBuyFeesEUR;
1609
+ const sellProceedsEUR = sellPricePerUnitEUR * matchedQty - allocatedSellFeesEUR;
1610
+ const gainLossEUR = sellProceedsEUR - buyCostEUR;
1611
+ matchedLots.push({
1612
+ isin: tx.isin,
1613
+ product: tx.product,
1614
+ quantity: matchedQty,
1615
+ buyDate: lot.date,
1616
+ sellDate: tx.date,
1617
+ buyPriceEUR: lot.pricePerUnitEUR,
1618
+ sellPriceEUR: sellPricePerUnitEUR,
1619
+ buyCostEUR: roundHalfUp2(buyCostEUR),
1620
+ sellProceedsEUR: roundHalfUp2(sellProceedsEUR),
1621
+ gainLossEUR: roundHalfUp2(gainLossEUR),
1622
+ buyFxRate: lot.fxRate,
1623
+ sellFxRate: tx.fxRate
1624
+ });
1625
+ lot.quantity -= matchedQty;
1626
+ remainingSellQty -= matchedQty;
1627
+ if (lot.quantity <= 0) {
1628
+ if (method === "LIFO") {
1629
+ lots.pop();
1630
+ } else {
1631
+ lots.shift();
1632
+ }
1633
+ }
1634
+ }
1635
+ }
1636
+ }
1637
+ let plusvalenze = 0;
1638
+ let minusvalenze = 0;
1639
+ for (const lot of matchedLots) {
1640
+ if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
1641
+ else minusvalenze += Math.abs(lot.gainLossEUR);
1642
+ }
1643
+ if (this._options.classification) {
1644
+ const classification = this._options.classification;
1645
+ const unclassifiedIsins = /* @__PURE__ */ new Set();
1646
+ for (const lot of matchedLots) {
1647
+ const entry = classification[lot.isin];
1648
+ if (!entry) {
1649
+ lot.bucket = "B";
1650
+ unclassifiedIsins.add(lot.isin);
1651
+ } else if (entry.bucketGain === "A" && lot.gainLossEUR >= 0) {
1652
+ lot.bucket = "A";
1653
+ } else {
1654
+ lot.bucket = "B";
1655
+ }
1656
+ }
1657
+ for (const isin of unclassifiedIsins) {
1658
+ warnings.push(
1659
+ `ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`
1660
+ );
1661
+ }
1662
+ const bucketALots = matchedLots.filter((l) => l.bucket === "A");
1663
+ const groupsByRate = /* @__PURE__ */ new Map();
1664
+ for (const lot of bucketALots) {
1665
+ const entry = classification[lot.isin];
1666
+ const rate = entry.taxRate;
1667
+ if (!groupsByRate.has(rate))
1668
+ groupsByRate.set(rate, { assetClasses: /* @__PURE__ */ new Set(), plusvalenze: 0 });
1669
+ const g = groupsByRate.get(rate);
1670
+ g.assetClasses.add(entry.assetClass);
1671
+ g.plusvalenze += lot.gainLossEUR;
1672
+ }
1673
+ const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
1674
+ taxRate,
1675
+ assetClasses: [...g.assetClasses],
1676
+ plusvalenze: roundHalfUp2(g.plusvalenze),
1677
+ imposta: roundHalfUp2(g.plusvalenze * taxRate)
1678
+ }));
1679
+ const bucketAReport = {
1680
+ groups: bucketAGroups,
1681
+ totalImposta: roundHalfUp2(
1682
+ bucketAGroups.reduce((s, g) => s + g.imposta, 0)
1683
+ )
1684
+ };
1685
+ const bucketBLots = matchedLots.filter((l) => l.bucket === "B");
1686
+ let bPlusvalenze = 0;
1687
+ let bMinusvalenze = 0;
1688
+ for (const lot of bucketBLots) {
1689
+ if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
1690
+ else bMinusvalenze += Math.abs(lot.gainLossEUR);
1691
+ }
1692
+ bPlusvalenze = roundHalfUp2(bPlusvalenze);
1693
+ bMinusvalenze = roundHalfUp2(bMinusvalenze);
1694
+ const carryForwards = [...this._options.carryForward ?? []].sort(
1695
+ (a, b) => a.year - b.year
1696
+ );
1697
+ let remaining = bPlusvalenze - bMinusvalenze;
1698
+ let carryForwardApplied = 0;
1699
+ for (const entry of carryForwards) {
1700
+ if (taxYear - entry.year > 4) continue;
1701
+ if (remaining <= 0) break;
1702
+ const consumed = Math.min(entry.amount, remaining);
1703
+ carryForwardApplied += consumed;
1704
+ remaining -= consumed;
1705
+ }
1706
+ carryForwardApplied = roundHalfUp2(carryForwardApplied);
1707
+ const bNetResult = roundHalfUp2(
1708
+ bPlusvalenze - bMinusvalenze - carryForwardApplied
1709
+ );
1710
+ const carryForwardRemaining = roundHalfUp2(Math.max(0, -bNetResult));
1711
+ const bucketBReport = {
1712
+ plusvalenze: bPlusvalenze,
1713
+ minusvalenze: bMinusvalenze,
1714
+ carryForwardApplied,
1715
+ carryForwardRemaining,
1716
+ netResult: bNetResult
1717
+ };
1718
+ const allIncomeRows = this._options.incomeRows ?? [];
1719
+ const filteredIncomeRows = allIncomeRows.filter(
1720
+ (row) => new Date(row.date).getFullYear() === taxYear
1721
+ );
1722
+ if (filteredIncomeRows.length < allIncomeRows.length) {
1723
+ warnings.push(`Income rows outside tax year ${taxYear} were skipped.`);
1724
+ }
1725
+ const quadroRT = buildQuadroRT(
1726
+ bucketBReport,
1727
+ this._options.carryForward ?? [],
1728
+ taxYear
1729
+ );
1730
+ const quadroRM = buildQuadroRM(
1731
+ bucketAGroups.length > 0 ? bucketAReport : void 0,
1732
+ filteredIncomeRows,
1733
+ taxYear
1734
+ );
1735
+ const dichiarazioneReport = buildDichiarazioneReport(
1736
+ quadroRT,
1737
+ quadroRM,
1738
+ taxYear
1739
+ );
1740
+ return {
1741
+ method,
1742
+ taxYear,
1743
+ plusvalenze: roundHalfUp2(plusvalenze),
1744
+ minusvalenze: roundHalfUp2(minusvalenze),
1745
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
1746
+ lots: matchedLots,
1747
+ ratesUsed,
1748
+ warnings,
1749
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1750
+ bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
1751
+ bucketB: bucketBReport,
1752
+ dichiarazione: dichiarazioneReport
1753
+ };
1754
+ } else {
1755
+ const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
1756
+ const hasIePrefix = allIsins.some((isin) => isin.startsWith("IE"));
1757
+ const hasOther = allIsins.some((isin) => !isin.startsWith("IE"));
1758
+ if (hasIePrefix && hasOther) {
1759
+ warnings.push(
1760
+ "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"
1761
+ );
1762
+ }
1763
+ }
1764
+ return {
1765
+ method,
1766
+ taxYear,
1767
+ plusvalenze: roundHalfUp2(plusvalenze),
1768
+ minusvalenze: roundHalfUp2(minusvalenze),
1769
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
1770
+ lots: matchedLots,
1771
+ ratesUsed,
1772
+ warnings,
1773
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1774
+ };
1775
+ }
1776
+ };
1777
+
1778
+ // src/mcp/tools/calculate-gains.ts
1779
+ async function handleCalculateGains(args) {
1780
+ try {
1781
+ const report = new Calculator(args.transactions, args.parseWarnings ?? [], {
1782
+ classification: args.classification,
1783
+ carryForward: args.carryForward,
1784
+ incomeRows: args.incomeRows
1785
+ }).calculateGains(args.method);
1786
+ return {
1787
+ content: [
1788
+ {
1789
+ type: "text",
1790
+ text: JSON.stringify(report)
1791
+ }
1792
+ ]
1793
+ };
1794
+ } catch (err) {
1795
+ if (err instanceof CalculationError) return toCalculationErrorResult(err);
1796
+ throw err;
1797
+ }
1798
+ }
1799
+
1800
+ // src/mcp/server.ts
1801
+ var __dirname = path2.dirname(fileURLToPath2(import.meta.url));
1802
+ function getPackageVersion() {
1803
+ const pkgPath = path2.join(__dirname, "../../package.json");
1804
+ const pkg = JSON.parse(fs3.readFileSync(pkgPath, "utf8"));
1805
+ return pkg.version;
1806
+ }
1807
+ var TOOLS = [
1808
+ {
1809
+ name: "parse_transactions",
1810
+ description: "Parse a DEGIRO transactions CSV export into Transaction[].",
1811
+ inputSchema: parseTransactionsInputSchema
1812
+ },
1813
+ {
1814
+ name: "classify_instruments",
1815
+ description: "Classify transactions' ISINs into asset classes (stateless mode).",
1816
+ inputSchema: classifyInstrumentsInputSchema
1817
+ },
1818
+ {
1819
+ name: "calculate_gains",
1820
+ description: "Calculate LIFO/FIFO capital gains from transactions.",
1821
+ inputSchema: calculateGainsInputSchema
1822
+ }
1823
+ ];
1824
+ var TOOL_NAMES = new Set(TOOLS.map((t) => t.name));
1825
+ var ajv = new Ajv({ allErrors: true, strict: false });
1826
+ var VALIDATORS = {
1827
+ parse_transactions: ajv.compile(parseTransactionsInputSchema),
1828
+ classify_instruments: ajv.compile(classifyInstrumentsInputSchema),
1829
+ calculate_gains: ajv.compile(calculateGainsInputSchema)
1830
+ };
1831
+ var server = new Server(
1832
+ { name: "minus-tracker-mcp", version: getPackageVersion() },
1833
+ { capabilities: { tools: {} } }
1834
+ );
1835
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1836
+ tools: TOOLS
1837
+ }));
1838
+ server.setRequestHandler(CallToolRequestSchema, async (request2, extra) => {
1839
+ const { name, arguments: args } = request2.params;
1840
+ if (!TOOL_NAMES.has(name)) {
1841
+ return {
1842
+ isError: true,
1843
+ content: [{ type: "text", text: `Unknown tool: ${name}` }]
1844
+ };
1845
+ }
1846
+ const validate = VALIDATORS[name];
1847
+ if (validate && !validate(args)) {
1848
+ return {
1849
+ isError: true,
1850
+ content: [
1851
+ {
1852
+ type: "text",
1853
+ text: JSON.stringify({
1854
+ code: "VALIDATION_ERROR",
1855
+ errors: validate.errors
1856
+ })
1857
+ }
1858
+ ]
1859
+ };
1860
+ }
1861
+ switch (name) {
1862
+ case "parse_transactions":
1863
+ return handleParseTransactions(args);
1864
+ case "classify_instruments":
1865
+ return handleClassifyInstruments(
1866
+ args,
1867
+ {
1868
+ _meta: request2.params._meta,
1869
+ sendNotification: extra.sendNotification
1870
+ }
1871
+ );
1872
+ case "calculate_gains":
1873
+ return handleCalculateGains(args);
1874
+ default:
1875
+ return {
1876
+ isError: true,
1877
+ content: [{ type: "text", text: `Unknown tool: ${name}` }]
1878
+ };
1879
+ }
1880
+ });
1881
+
1882
+ // src/mcp/index.ts
1883
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1884
+ await server.connect(new StdioServerTransport());
1885
+ //# sourceMappingURL=index.js.map