@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.cjs CHANGED
@@ -32,6 +32,8 @@ var src_exports = {};
32
32
  __export(src_exports, {
33
33
  CalculationError: () => CalculationError,
34
34
  Calculator: () => Calculator,
35
+ ClassificationError: () => ClassificationError,
36
+ Classifier: () => Classifier,
35
37
  DEGIROParser: () => DEGIROParser,
36
38
  ParseError: () => ParseError
37
39
  });
@@ -59,6 +61,16 @@ var CalculationError = class extends Error {
59
61
  this.date = date;
60
62
  }
61
63
  };
64
+ var ClassificationError = class extends Error {
65
+ code;
66
+ constructor(code, message) {
67
+ super(
68
+ message ?? (code === "SIDECAR_NOT_FOUND" ? "Sidecar file not found" : code === "SIDECAR_VERSION" ? "Sidecar version mismatch" : code === "SIDECAR_MALFORMED" ? "Sidecar file is malformed JSON" : code === "NETWORK_ERROR" ? "Network error contacting OpenFIGI" : "Failed to write sidecar file")
69
+ );
70
+ this.name = "ClassificationError";
71
+ this.code = code;
72
+ }
73
+ };
62
74
 
63
75
  // src/rates/index.ts
64
76
  var import_node_url = require("url");
@@ -199,6 +211,17 @@ var DEGIROParser = class {
199
211
  constructor(snapshot) {
200
212
  this._snapshot = snapshot;
201
213
  }
214
+ /**
215
+ * Parse a DEGIRO Transactions CSV export string.
216
+ *
217
+ * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
218
+ * @returns Array of normalised Transaction objects. Empty if no data rows are found.
219
+ * @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
220
+ * @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
221
+ *
222
+ * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
223
+ * 3 trading days are skipped silently. Inspect `parser.warnings` for details.
224
+ */
202
225
  parse(csv) {
203
226
  this._warningEntries = [];
204
227
  if (typeof csv !== "string" || csv.includes("\0")) {
@@ -322,10 +345,21 @@ function inferTaxYear(transactions) {
322
345
  var Calculator = class {
323
346
  _transactions;
324
347
  _parseWarnings;
325
- constructor(transactions, parseWarnings) {
348
+ _options;
349
+ constructor(transactions, parseWarnings, options) {
326
350
  this._transactions = transactions;
327
351
  this._parseWarnings = parseWarnings ?? [];
352
+ this._options = options ?? {};
328
353
  }
354
+ /**
355
+ * Run LIFO or FIFO lot-matching over the transaction list.
356
+ *
357
+ * @param method - `"LIFO"` or `"FIFO"`.
358
+ * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
359
+ * ECB rates used, and any accumulated warnings.
360
+ * @throws {CalculationError} when a SELL has no matching open buy lots.
361
+ * `error.isin` and `error.date` identify the problematic transaction.
362
+ */
329
363
  calculateGains(method) {
330
364
  const warnings = [...this._parseWarnings];
331
365
  const sorted = [...this._transactions].sort((a, b) => {
@@ -409,6 +443,104 @@ var Calculator = class {
409
443
  if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
410
444
  else minusvalenze += Math.abs(lot.gainLossEUR);
411
445
  }
446
+ if (this._options.classification) {
447
+ const classification = this._options.classification;
448
+ const unclassifiedIsins = /* @__PURE__ */ new Set();
449
+ for (const lot of matchedLots) {
450
+ const entry = classification[lot.isin];
451
+ if (!entry) {
452
+ lot.bucket = "B";
453
+ unclassifiedIsins.add(lot.isin);
454
+ } else if (entry.bucketGain === "A" && lot.gainLossEUR >= 0) {
455
+ lot.bucket = "A";
456
+ } else {
457
+ lot.bucket = "B";
458
+ }
459
+ }
460
+ for (const isin of unclassifiedIsins) {
461
+ warnings.push(
462
+ `ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`
463
+ );
464
+ }
465
+ const bucketALots = matchedLots.filter((l) => l.bucket === "A");
466
+ const groupsByRate = /* @__PURE__ */ new Map();
467
+ for (const lot of bucketALots) {
468
+ const entry = classification[lot.isin];
469
+ const rate = entry.taxRate;
470
+ if (!groupsByRate.has(rate))
471
+ groupsByRate.set(rate, { assetClasses: /* @__PURE__ */ new Set(), plusvalenze: 0 });
472
+ const g = groupsByRate.get(rate);
473
+ g.assetClasses.add(entry.assetClass);
474
+ g.plusvalenze += lot.gainLossEUR;
475
+ }
476
+ const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
477
+ taxRate,
478
+ assetClasses: [...g.assetClasses],
479
+ plusvalenze: roundHalfUp(g.plusvalenze),
480
+ imposta: roundHalfUp(g.plusvalenze * taxRate)
481
+ }));
482
+ const bucketAReport = {
483
+ groups: bucketAGroups,
484
+ totalImposta: roundHalfUp(
485
+ bucketAGroups.reduce((s, g) => s + g.imposta, 0)
486
+ )
487
+ };
488
+ const bucketBLots = matchedLots.filter((l) => l.bucket === "B");
489
+ let bPlusvalenze = 0;
490
+ let bMinusvalenze = 0;
491
+ for (const lot of bucketBLots) {
492
+ if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
493
+ else bMinusvalenze += Math.abs(lot.gainLossEUR);
494
+ }
495
+ bPlusvalenze = roundHalfUp(bPlusvalenze);
496
+ bMinusvalenze = roundHalfUp(bMinusvalenze);
497
+ const carryForwards = [...this._options.carryForward ?? []].sort(
498
+ (a, b) => a.year - b.year
499
+ );
500
+ let remaining = bPlusvalenze - bMinusvalenze;
501
+ let carryForwardApplied = 0;
502
+ for (const entry of carryForwards) {
503
+ if (taxYear - entry.year > 4) continue;
504
+ if (remaining <= 0) break;
505
+ const consumed = Math.min(entry.amount, remaining);
506
+ carryForwardApplied += consumed;
507
+ remaining -= consumed;
508
+ }
509
+ carryForwardApplied = roundHalfUp(carryForwardApplied);
510
+ const bNetResult = roundHalfUp(
511
+ bPlusvalenze - bMinusvalenze - carryForwardApplied
512
+ );
513
+ const carryForwardRemaining = roundHalfUp(Math.max(0, -bNetResult));
514
+ const bucketBReport = {
515
+ plusvalenze: bPlusvalenze,
516
+ minusvalenze: bMinusvalenze,
517
+ carryForwardApplied,
518
+ carryForwardRemaining,
519
+ netResult: bNetResult
520
+ };
521
+ return {
522
+ method,
523
+ taxYear,
524
+ plusvalenze: roundHalfUp(plusvalenze),
525
+ minusvalenze: roundHalfUp(minusvalenze),
526
+ netResult: roundHalfUp(plusvalenze - minusvalenze),
527
+ lots: matchedLots,
528
+ ratesUsed,
529
+ warnings,
530
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
531
+ bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
532
+ bucketB: bucketBReport
533
+ };
534
+ } else {
535
+ const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
536
+ const hasIePrefix = allIsins.some((isin) => isin.startsWith("IE"));
537
+ const hasOther = allIsins.some((isin) => !isin.startsWith("IE"));
538
+ if (hasIePrefix && hasOther) {
539
+ warnings.push(
540
+ "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"
541
+ );
542
+ }
543
+ }
412
544
  return {
413
545
  method,
414
546
  taxYear,
@@ -422,10 +554,358 @@ var Calculator = class {
422
554
  };
423
555
  }
424
556
  };
557
+
558
+ // src/classifier/index.ts
559
+ var fs2 = __toESM(require("fs"), 1);
560
+ var https = __toESM(require("https"), 1);
561
+ function httpsPost(url, body, timeoutMs) {
562
+ return new Promise((resolve, reject) => {
563
+ const u = new URL(url);
564
+ const req = https.request(
565
+ {
566
+ hostname: u.hostname,
567
+ path: u.pathname + u.search,
568
+ method: "POST",
569
+ headers: {
570
+ "Content-Type": "application/json",
571
+ "Content-Length": Buffer.byteLength(body)
572
+ }
573
+ },
574
+ (res) => {
575
+ let data = "";
576
+ res.on("data", (chunk) => {
577
+ data += chunk;
578
+ });
579
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, data }));
580
+ }
581
+ );
582
+ req.setTimeout(timeoutMs, () => {
583
+ req.destroy();
584
+ reject(new Error("timeout"));
585
+ });
586
+ req.on("error", reject);
587
+ req.write(body);
588
+ req.end();
589
+ });
590
+ }
591
+ var GOVT_BOND_WHITELIST = /* @__PURE__ */ new Set([
592
+ "IT",
593
+ "DE",
594
+ "FR",
595
+ "AT",
596
+ "BE",
597
+ "NL",
598
+ "ES",
599
+ "PT",
600
+ "FI",
601
+ "IE",
602
+ "LU",
603
+ "GR",
604
+ "SK",
605
+ "SI",
606
+ "LT",
607
+ "LV",
608
+ "EE",
609
+ "MT",
610
+ "CY",
611
+ "US",
612
+ "GB",
613
+ "CH",
614
+ "NO",
615
+ "SE",
616
+ "DK",
617
+ "JP",
618
+ "CA",
619
+ "AU",
620
+ "NZ",
621
+ "SG",
622
+ "HK",
623
+ "KR"
624
+ ]);
625
+ var SECURITY_TYPE_MAP = {
626
+ ETP: {
627
+ assetClass: "ETF",
628
+ bucketGain: "A",
629
+ bucketLoss: "B",
630
+ taxRate: 0.26,
631
+ whiteListed: null
632
+ },
633
+ ETF: {
634
+ assetClass: "ETF",
635
+ bucketGain: "A",
636
+ bucketLoss: "B",
637
+ taxRate: 0.26,
638
+ whiteListed: null
639
+ },
640
+ "Mutual Fund": {
641
+ assetClass: "ETF",
642
+ bucketGain: "A",
643
+ bucketLoss: "B",
644
+ taxRate: 0.26,
645
+ whiteListed: null
646
+ },
647
+ "Open-End Fund": {
648
+ assetClass: "ETF",
649
+ bucketGain: "A",
650
+ bucketLoss: "B",
651
+ taxRate: 0.26,
652
+ whiteListed: null
653
+ },
654
+ "Common Stock": {
655
+ assetClass: "Stock",
656
+ bucketGain: "B",
657
+ bucketLoss: "B",
658
+ taxRate: 0,
659
+ whiteListed: null
660
+ },
661
+ Equity: {
662
+ assetClass: "Stock",
663
+ bucketGain: "B",
664
+ bucketLoss: "B",
665
+ taxRate: 0,
666
+ whiteListed: null
667
+ },
668
+ "Exchange Traded Commodity": {
669
+ assetClass: "ETC",
670
+ bucketGain: "B",
671
+ bucketLoss: "B",
672
+ taxRate: 0,
673
+ whiteListed: null
674
+ },
675
+ "Corporate Bond": {
676
+ assetClass: "CorpBond",
677
+ bucketGain: "B",
678
+ bucketLoss: "B",
679
+ taxRate: 0,
680
+ whiteListed: null
681
+ },
682
+ Option: {
683
+ assetClass: "Derivative",
684
+ bucketGain: "B",
685
+ bucketLoss: "B",
686
+ taxRate: 0,
687
+ whiteListed: null
688
+ },
689
+ Future: {
690
+ assetClass: "Derivative",
691
+ bucketGain: "B",
692
+ bucketLoss: "B",
693
+ taxRate: 0,
694
+ whiteListed: null
695
+ },
696
+ Warrant: {
697
+ assetClass: "Derivative",
698
+ bucketGain: "B",
699
+ bucketLoss: "B",
700
+ taxRate: 0,
701
+ whiteListed: null
702
+ },
703
+ CFD: {
704
+ assetClass: "Derivative",
705
+ bucketGain: "B",
706
+ bucketLoss: "B",
707
+ taxRate: 0,
708
+ whiteListed: null
709
+ },
710
+ Swap: {
711
+ assetClass: "Derivative",
712
+ bucketGain: "B",
713
+ bucketLoss: "B",
714
+ taxRate: 0,
715
+ whiteListed: null
716
+ },
717
+ "Leverage Certificate": {
718
+ assetClass: "LeverageCert",
719
+ bucketGain: "B",
720
+ bucketLoss: "B",
721
+ taxRate: 0,
722
+ whiteListed: null
723
+ },
724
+ "Turbo Certificate": {
725
+ assetClass: "LeverageCert",
726
+ bucketGain: "B",
727
+ bucketLoss: "B",
728
+ taxRate: 0,
729
+ whiteListed: null
730
+ },
731
+ "Capital Protected Certificate": {
732
+ assetClass: "CapProtectedCert",
733
+ bucketGain: "A",
734
+ bucketLoss: "B",
735
+ taxRate: 0.26,
736
+ whiteListed: null
737
+ }
738
+ };
739
+ function isKnownType(t) {
740
+ return t === "Government Bond" || t in SECURITY_TYPE_MAP;
741
+ }
742
+ function classifyByType(isin, securityType, product, warnings) {
743
+ if (securityType === "Government Bond") {
744
+ const prefix = isin.slice(0, 2).toUpperCase();
745
+ const whiteListed = GOVT_BOND_WHITELIST.has(prefix);
746
+ return {
747
+ product,
748
+ assetClass: whiteListed ? "GovtBondWL" : "GovtBondOther",
749
+ bucketGain: "A",
750
+ bucketLoss: "B",
751
+ taxRate: whiteListed ? 0.125 : 0.26,
752
+ whiteListed,
753
+ confirmedByUser: false,
754
+ source: "openfigi"
755
+ };
756
+ }
757
+ const mapped = SECURITY_TYPE_MAP[securityType];
758
+ if (mapped) {
759
+ return {
760
+ product,
761
+ assetClass: mapped.assetClass,
762
+ bucketGain: mapped.bucketGain,
763
+ bucketLoss: mapped.bucketLoss,
764
+ taxRate: mapped.taxRate,
765
+ whiteListed: mapped.whiteListed,
766
+ confirmedByUser: false,
767
+ source: "openfigi"
768
+ };
769
+ }
770
+ warnings.push(
771
+ `Unrecognized type: ${securityType}. Please classify manually.`
772
+ );
773
+ return {
774
+ product,
775
+ assetClass: "Stock",
776
+ bucketGain: "B",
777
+ bucketLoss: "B",
778
+ taxRate: 0,
779
+ whiteListed: null,
780
+ confirmedByUser: false,
781
+ source: "user"
782
+ };
783
+ }
784
+ var Classifier = class {
785
+ interactive;
786
+ constructor(options) {
787
+ this.interactive = options?.interactive ?? true;
788
+ }
789
+ async load(sidecarPath) {
790
+ if (!fs2.existsSync(sidecarPath)) {
791
+ throw new ClassificationError("SIDECAR_NOT_FOUND");
792
+ }
793
+ let parsed;
794
+ try {
795
+ const raw = fs2.readFileSync(sidecarPath, "utf-8");
796
+ parsed = JSON.parse(raw);
797
+ } catch {
798
+ throw new ClassificationError("SIDECAR_MALFORMED");
799
+ }
800
+ if (typeof parsed !== "object" || parsed === null || parsed["version"] !== 1) {
801
+ throw new ClassificationError("SIDECAR_VERSION");
802
+ }
803
+ return parsed.classifications;
804
+ }
805
+ async classify(transactions, sidecarPath, _httpPost = httpsPost) {
806
+ const isinToProduct = /* @__PURE__ */ new Map();
807
+ for (const tx of transactions) {
808
+ if (tx.isin && !isinToProduct.has(tx.isin)) {
809
+ isinToProduct.set(tx.isin, tx.product);
810
+ }
811
+ }
812
+ const confirmed = {};
813
+ if (fs2.existsSync(sidecarPath)) {
814
+ const existingMap = await this.load(sidecarPath);
815
+ for (const [isin, entry] of Object.entries(existingMap)) {
816
+ if (entry.confirmedByUser) {
817
+ confirmed[isin] = entry;
818
+ }
819
+ }
820
+ }
821
+ const toProcess = [];
822
+ for (const isin of isinToProduct.keys()) {
823
+ if (!(isin in confirmed)) {
824
+ toProcess.push(isin);
825
+ }
826
+ }
827
+ const warnings = [];
828
+ const newEntries = {};
829
+ const BATCH_SIZE = 10;
830
+ for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
831
+ if (i > 0) {
832
+ await new Promise((resolve) => setTimeout(resolve, 6e3));
833
+ }
834
+ const batch = toProcess.slice(i, i + BATCH_SIZE);
835
+ const requestBody = JSON.stringify(
836
+ batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
837
+ );
838
+ let response = null;
839
+ for (let attempt = 0; attempt < 2; attempt++) {
840
+ try {
841
+ const r = await _httpPost(
842
+ "https://api.openfigi.com/v3/mapping",
843
+ requestBody,
844
+ 1e4
845
+ );
846
+ if (r.status < 500) {
847
+ response = r;
848
+ break;
849
+ }
850
+ } catch {
851
+ throw new ClassificationError("NETWORK_ERROR");
852
+ }
853
+ }
854
+ if (response === null) {
855
+ throw new ClassificationError("NETWORK_ERROR");
856
+ }
857
+ const results = JSON.parse(response.data);
858
+ for (let j = 0; j < batch.length; j++) {
859
+ const isin = batch[j];
860
+ const result = results[j];
861
+ const product = isinToProduct.get(isin) ?? isin;
862
+ if (!result || result.error || !result.data || result.data.length === 0) {
863
+ warnings.push(
864
+ `Unrecognized type: unknown. Please classify manually.`
865
+ );
866
+ newEntries[isin] = {
867
+ product,
868
+ assetClass: "Stock",
869
+ bucketGain: "B",
870
+ bucketLoss: "B",
871
+ taxRate: 0,
872
+ whiteListed: null,
873
+ confirmedByUser: false,
874
+ source: "user"
875
+ };
876
+ continue;
877
+ }
878
+ const st = result.data[0]?.securityType;
879
+ const st2 = result.data[0]?.securityType2;
880
+ const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
881
+ newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
882
+ }
883
+ }
884
+ const mergedMap = { ...newEntries, ...confirmed };
885
+ const sidecarContent = JSON.stringify(
886
+ {
887
+ version: 1,
888
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
889
+ classifications: mergedMap,
890
+ warnings
891
+ },
892
+ null,
893
+ 2
894
+ );
895
+ try {
896
+ fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
897
+ } catch {
898
+ throw new ClassificationError("WRITE_ERROR");
899
+ }
900
+ return mergedMap;
901
+ }
902
+ };
425
903
  // Annotate the CommonJS export names for ESM import in node:
426
904
  0 && (module.exports = {
427
905
  CalculationError,
428
906
  Calculator,
907
+ ClassificationError,
908
+ Classifier,
429
909
  DEGIROParser,
430
910
  ParseError
431
911
  });