@gabrielerandelli/minus-tracker 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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";
@@ -159,6 +169,17 @@ var DEGIROParser = class {
159
169
  constructor(snapshot) {
160
170
  this._snapshot = snapshot;
161
171
  }
172
+ /**
173
+ * Parse a DEGIRO Transactions CSV export string.
174
+ *
175
+ * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
176
+ * @returns Array of normalised Transaction objects. Empty if no data rows are found.
177
+ * @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
178
+ * @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
179
+ *
180
+ * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
181
+ * 3 trading days are skipped silently. Inspect `parser.warnings` for details.
182
+ */
162
183
  parse(csv) {
163
184
  this._warningEntries = [];
164
185
  if (typeof csv !== "string" || csv.includes("\0")) {
@@ -282,10 +303,21 @@ function inferTaxYear(transactions) {
282
303
  var Calculator = class {
283
304
  _transactions;
284
305
  _parseWarnings;
285
- constructor(transactions, parseWarnings) {
306
+ _options;
307
+ constructor(transactions, parseWarnings, options) {
286
308
  this._transactions = transactions;
287
309
  this._parseWarnings = parseWarnings ?? [];
310
+ this._options = options ?? {};
288
311
  }
312
+ /**
313
+ * Run LIFO or FIFO lot-matching over the transaction list.
314
+ *
315
+ * @param method - `"LIFO"` or `"FIFO"`.
316
+ * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
317
+ * ECB rates used, and any accumulated warnings.
318
+ * @throws {CalculationError} when a SELL has no matching open buy lots.
319
+ * `error.isin` and `error.date` identify the problematic transaction.
320
+ */
289
321
  calculateGains(method) {
290
322
  const warnings = [...this._parseWarnings];
291
323
  const sorted = [...this._transactions].sort((a, b) => {
@@ -369,6 +401,104 @@ var Calculator = class {
369
401
  if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
370
402
  else minusvalenze += Math.abs(lot.gainLossEUR);
371
403
  }
404
+ if (this._options.classification) {
405
+ const classification = this._options.classification;
406
+ const unclassifiedIsins = /* @__PURE__ */ new Set();
407
+ for (const lot of matchedLots) {
408
+ const entry = classification[lot.isin];
409
+ if (!entry) {
410
+ lot.bucket = "B";
411
+ unclassifiedIsins.add(lot.isin);
412
+ } else if (entry.bucketGain === "A" && lot.gainLossEUR >= 0) {
413
+ lot.bucket = "A";
414
+ } else {
415
+ lot.bucket = "B";
416
+ }
417
+ }
418
+ for (const isin of unclassifiedIsins) {
419
+ warnings.push(
420
+ `ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`
421
+ );
422
+ }
423
+ const bucketALots = matchedLots.filter((l) => l.bucket === "A");
424
+ const groupsByRate = /* @__PURE__ */ new Map();
425
+ for (const lot of bucketALots) {
426
+ const entry = classification[lot.isin];
427
+ const rate = entry.taxRate;
428
+ if (!groupsByRate.has(rate))
429
+ groupsByRate.set(rate, { assetClasses: /* @__PURE__ */ new Set(), plusvalenze: 0 });
430
+ const g = groupsByRate.get(rate);
431
+ g.assetClasses.add(entry.assetClass);
432
+ g.plusvalenze += lot.gainLossEUR;
433
+ }
434
+ const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
435
+ taxRate,
436
+ assetClasses: [...g.assetClasses],
437
+ plusvalenze: roundHalfUp(g.plusvalenze),
438
+ imposta: roundHalfUp(g.plusvalenze * taxRate)
439
+ }));
440
+ const bucketAReport = {
441
+ groups: bucketAGroups,
442
+ totalImposta: roundHalfUp(
443
+ bucketAGroups.reduce((s, g) => s + g.imposta, 0)
444
+ )
445
+ };
446
+ const bucketBLots = matchedLots.filter((l) => l.bucket === "B");
447
+ let bPlusvalenze = 0;
448
+ let bMinusvalenze = 0;
449
+ for (const lot of bucketBLots) {
450
+ if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
451
+ else bMinusvalenze += Math.abs(lot.gainLossEUR);
452
+ }
453
+ bPlusvalenze = roundHalfUp(bPlusvalenze);
454
+ bMinusvalenze = roundHalfUp(bMinusvalenze);
455
+ const carryForwards = [...this._options.carryForward ?? []].sort(
456
+ (a, b) => a.year - b.year
457
+ );
458
+ let remaining = bPlusvalenze - bMinusvalenze;
459
+ let carryForwardApplied = 0;
460
+ for (const entry of carryForwards) {
461
+ if (taxYear - entry.year > 4) continue;
462
+ if (remaining <= 0) break;
463
+ const consumed = Math.min(entry.amount, remaining);
464
+ carryForwardApplied += consumed;
465
+ remaining -= consumed;
466
+ }
467
+ carryForwardApplied = roundHalfUp(carryForwardApplied);
468
+ const bNetResult = roundHalfUp(
469
+ bPlusvalenze - bMinusvalenze - carryForwardApplied
470
+ );
471
+ const carryForwardRemaining = roundHalfUp(Math.max(0, -bNetResult));
472
+ const bucketBReport = {
473
+ plusvalenze: bPlusvalenze,
474
+ minusvalenze: bMinusvalenze,
475
+ carryForwardApplied,
476
+ carryForwardRemaining,
477
+ netResult: bNetResult
478
+ };
479
+ return {
480
+ method,
481
+ taxYear,
482
+ plusvalenze: roundHalfUp(plusvalenze),
483
+ minusvalenze: roundHalfUp(minusvalenze),
484
+ netResult: roundHalfUp(plusvalenze - minusvalenze),
485
+ lots: matchedLots,
486
+ ratesUsed,
487
+ warnings,
488
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
489
+ bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
490
+ bucketB: bucketBReport
491
+ };
492
+ } else {
493
+ const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
494
+ const hasIePrefix = allIsins.some((isin) => isin.startsWith("IE"));
495
+ const hasOther = allIsins.some((isin) => !isin.startsWith("IE"));
496
+ if (hasIePrefix && hasOther) {
497
+ warnings.push(
498
+ "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"
499
+ );
500
+ }
501
+ }
372
502
  return {
373
503
  method,
374
504
  taxYear,
@@ -382,9 +512,357 @@ var Calculator = class {
382
512
  };
383
513
  }
384
514
  };
515
+
516
+ // src/classifier/index.ts
517
+ import * as fs2 from "fs";
518
+ import * as https from "https";
519
+ function httpsPost(url, body, timeoutMs) {
520
+ return new Promise((resolve, reject) => {
521
+ const u = new URL(url);
522
+ const req = https.request(
523
+ {
524
+ hostname: u.hostname,
525
+ path: u.pathname + u.search,
526
+ method: "POST",
527
+ headers: {
528
+ "Content-Type": "application/json",
529
+ "Content-Length": Buffer.byteLength(body)
530
+ }
531
+ },
532
+ (res) => {
533
+ let data = "";
534
+ res.on("data", (chunk) => {
535
+ data += chunk;
536
+ });
537
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, data }));
538
+ }
539
+ );
540
+ req.setTimeout(timeoutMs, () => {
541
+ req.destroy();
542
+ reject(new Error("timeout"));
543
+ });
544
+ req.on("error", reject);
545
+ req.write(body);
546
+ req.end();
547
+ });
548
+ }
549
+ var GOVT_BOND_WHITELIST = /* @__PURE__ */ new Set([
550
+ "IT",
551
+ "DE",
552
+ "FR",
553
+ "AT",
554
+ "BE",
555
+ "NL",
556
+ "ES",
557
+ "PT",
558
+ "FI",
559
+ "IE",
560
+ "LU",
561
+ "GR",
562
+ "SK",
563
+ "SI",
564
+ "LT",
565
+ "LV",
566
+ "EE",
567
+ "MT",
568
+ "CY",
569
+ "US",
570
+ "GB",
571
+ "CH",
572
+ "NO",
573
+ "SE",
574
+ "DK",
575
+ "JP",
576
+ "CA",
577
+ "AU",
578
+ "NZ",
579
+ "SG",
580
+ "HK",
581
+ "KR"
582
+ ]);
583
+ var SECURITY_TYPE_MAP = {
584
+ ETP: {
585
+ assetClass: "ETF",
586
+ bucketGain: "A",
587
+ bucketLoss: "B",
588
+ taxRate: 0.26,
589
+ whiteListed: null
590
+ },
591
+ ETF: {
592
+ assetClass: "ETF",
593
+ bucketGain: "A",
594
+ bucketLoss: "B",
595
+ taxRate: 0.26,
596
+ whiteListed: null
597
+ },
598
+ "Mutual Fund": {
599
+ assetClass: "ETF",
600
+ bucketGain: "A",
601
+ bucketLoss: "B",
602
+ taxRate: 0.26,
603
+ whiteListed: null
604
+ },
605
+ "Open-End Fund": {
606
+ assetClass: "ETF",
607
+ bucketGain: "A",
608
+ bucketLoss: "B",
609
+ taxRate: 0.26,
610
+ whiteListed: null
611
+ },
612
+ "Common Stock": {
613
+ assetClass: "Stock",
614
+ bucketGain: "B",
615
+ bucketLoss: "B",
616
+ taxRate: 0,
617
+ whiteListed: null
618
+ },
619
+ Equity: {
620
+ assetClass: "Stock",
621
+ bucketGain: "B",
622
+ bucketLoss: "B",
623
+ taxRate: 0,
624
+ whiteListed: null
625
+ },
626
+ "Exchange Traded Commodity": {
627
+ assetClass: "ETC",
628
+ bucketGain: "B",
629
+ bucketLoss: "B",
630
+ taxRate: 0,
631
+ whiteListed: null
632
+ },
633
+ "Corporate Bond": {
634
+ assetClass: "CorpBond",
635
+ bucketGain: "B",
636
+ bucketLoss: "B",
637
+ taxRate: 0,
638
+ whiteListed: null
639
+ },
640
+ Option: {
641
+ assetClass: "Derivative",
642
+ bucketGain: "B",
643
+ bucketLoss: "B",
644
+ taxRate: 0,
645
+ whiteListed: null
646
+ },
647
+ Future: {
648
+ assetClass: "Derivative",
649
+ bucketGain: "B",
650
+ bucketLoss: "B",
651
+ taxRate: 0,
652
+ whiteListed: null
653
+ },
654
+ Warrant: {
655
+ assetClass: "Derivative",
656
+ bucketGain: "B",
657
+ bucketLoss: "B",
658
+ taxRate: 0,
659
+ whiteListed: null
660
+ },
661
+ CFD: {
662
+ assetClass: "Derivative",
663
+ bucketGain: "B",
664
+ bucketLoss: "B",
665
+ taxRate: 0,
666
+ whiteListed: null
667
+ },
668
+ Swap: {
669
+ assetClass: "Derivative",
670
+ bucketGain: "B",
671
+ bucketLoss: "B",
672
+ taxRate: 0,
673
+ whiteListed: null
674
+ },
675
+ "Leverage Certificate": {
676
+ assetClass: "LeverageCert",
677
+ bucketGain: "B",
678
+ bucketLoss: "B",
679
+ taxRate: 0,
680
+ whiteListed: null
681
+ },
682
+ "Turbo Certificate": {
683
+ assetClass: "LeverageCert",
684
+ bucketGain: "B",
685
+ bucketLoss: "B",
686
+ taxRate: 0,
687
+ whiteListed: null
688
+ },
689
+ "Capital Protected Certificate": {
690
+ assetClass: "CapProtectedCert",
691
+ bucketGain: "A",
692
+ bucketLoss: "B",
693
+ taxRate: 0.26,
694
+ whiteListed: null
695
+ }
696
+ };
697
+ function isKnownType(t) {
698
+ return t === "Government Bond" || t in SECURITY_TYPE_MAP;
699
+ }
700
+ function classifyByType(isin, securityType, product, warnings) {
701
+ if (securityType === "Government Bond") {
702
+ const prefix = isin.slice(0, 2).toUpperCase();
703
+ const whiteListed = GOVT_BOND_WHITELIST.has(prefix);
704
+ return {
705
+ product,
706
+ assetClass: whiteListed ? "GovtBondWL" : "GovtBondOther",
707
+ bucketGain: "A",
708
+ bucketLoss: "B",
709
+ taxRate: whiteListed ? 0.125 : 0.26,
710
+ whiteListed,
711
+ confirmedByUser: false,
712
+ source: "openfigi"
713
+ };
714
+ }
715
+ const mapped = SECURITY_TYPE_MAP[securityType];
716
+ if (mapped) {
717
+ return {
718
+ product,
719
+ assetClass: mapped.assetClass,
720
+ bucketGain: mapped.bucketGain,
721
+ bucketLoss: mapped.bucketLoss,
722
+ taxRate: mapped.taxRate,
723
+ whiteListed: mapped.whiteListed,
724
+ confirmedByUser: false,
725
+ source: "openfigi"
726
+ };
727
+ }
728
+ warnings.push(
729
+ `Unrecognized type: ${securityType}. Please classify manually.`
730
+ );
731
+ return {
732
+ product,
733
+ assetClass: "Stock",
734
+ bucketGain: "B",
735
+ bucketLoss: "B",
736
+ taxRate: 0,
737
+ whiteListed: null,
738
+ confirmedByUser: false,
739
+ source: "user"
740
+ };
741
+ }
742
+ var Classifier = class {
743
+ interactive;
744
+ constructor(options) {
745
+ this.interactive = options?.interactive ?? true;
746
+ }
747
+ async load(sidecarPath) {
748
+ if (!fs2.existsSync(sidecarPath)) {
749
+ throw new ClassificationError("SIDECAR_NOT_FOUND");
750
+ }
751
+ let parsed;
752
+ try {
753
+ const raw = fs2.readFileSync(sidecarPath, "utf-8");
754
+ parsed = JSON.parse(raw);
755
+ } catch {
756
+ throw new ClassificationError("SIDECAR_MALFORMED");
757
+ }
758
+ if (typeof parsed !== "object" || parsed === null || parsed["version"] !== 1) {
759
+ throw new ClassificationError("SIDECAR_VERSION");
760
+ }
761
+ return parsed.classifications;
762
+ }
763
+ async classify(transactions, sidecarPath, _httpPost = httpsPost) {
764
+ const isinToProduct = /* @__PURE__ */ new Map();
765
+ for (const tx of transactions) {
766
+ if (tx.isin && !isinToProduct.has(tx.isin)) {
767
+ isinToProduct.set(tx.isin, tx.product);
768
+ }
769
+ }
770
+ const confirmed = {};
771
+ if (fs2.existsSync(sidecarPath)) {
772
+ const existingMap = await this.load(sidecarPath);
773
+ for (const [isin, entry] of Object.entries(existingMap)) {
774
+ if (entry.confirmedByUser) {
775
+ confirmed[isin] = entry;
776
+ }
777
+ }
778
+ }
779
+ const toProcess = [];
780
+ for (const isin of isinToProduct.keys()) {
781
+ if (!(isin in confirmed)) {
782
+ toProcess.push(isin);
783
+ }
784
+ }
785
+ const warnings = [];
786
+ const newEntries = {};
787
+ const BATCH_SIZE = 10;
788
+ for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
789
+ if (i > 0) {
790
+ await new Promise((resolve) => setTimeout(resolve, 6e3));
791
+ }
792
+ const batch = toProcess.slice(i, i + BATCH_SIZE);
793
+ const requestBody = JSON.stringify(
794
+ batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
795
+ );
796
+ let response = null;
797
+ for (let attempt = 0; attempt < 2; attempt++) {
798
+ try {
799
+ const r = await _httpPost(
800
+ "https://api.openfigi.com/v3/mapping",
801
+ requestBody,
802
+ 1e4
803
+ );
804
+ if (r.status < 500) {
805
+ response = r;
806
+ break;
807
+ }
808
+ } catch {
809
+ throw new ClassificationError("NETWORK_ERROR");
810
+ }
811
+ }
812
+ if (response === null) {
813
+ throw new ClassificationError("NETWORK_ERROR");
814
+ }
815
+ const results = JSON.parse(response.data);
816
+ for (let j = 0; j < batch.length; j++) {
817
+ const isin = batch[j];
818
+ const result = results[j];
819
+ const product = isinToProduct.get(isin) ?? isin;
820
+ if (!result || result.error || !result.data || result.data.length === 0) {
821
+ warnings.push(
822
+ `Unrecognized type: unknown. Please classify manually.`
823
+ );
824
+ newEntries[isin] = {
825
+ product,
826
+ assetClass: "Stock",
827
+ bucketGain: "B",
828
+ bucketLoss: "B",
829
+ taxRate: 0,
830
+ whiteListed: null,
831
+ confirmedByUser: false,
832
+ source: "user"
833
+ };
834
+ continue;
835
+ }
836
+ const st = result.data[0]?.securityType;
837
+ const st2 = result.data[0]?.securityType2;
838
+ const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
839
+ newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
840
+ }
841
+ }
842
+ const mergedMap = { ...newEntries, ...confirmed };
843
+ const sidecarContent = JSON.stringify(
844
+ {
845
+ version: 1,
846
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
847
+ classifications: mergedMap,
848
+ warnings
849
+ },
850
+ null,
851
+ 2
852
+ );
853
+ try {
854
+ fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
855
+ } catch {
856
+ throw new ClassificationError("WRITE_ERROR");
857
+ }
858
+ return mergedMap;
859
+ }
860
+ };
385
861
  export {
386
862
  CalculationError,
387
863
  Calculator,
864
+ ClassificationError,
865
+ Classifier,
388
866
  DEGIROParser,
389
867
  ParseError
390
868
  };