@gabrielerandelli/minus-tracker 0.5.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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");
@@ -140,6 +152,10 @@ function warningToEnglish(w) {
140
152
  return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
141
153
  case "QUANTITY_ZERO":
142
154
  return `Row ${w.row}: quantity is 0 \u2014 skipped`;
155
+ case "MISSING_ISIN_INCOME":
156
+ return `Row ${w.row}: blank ISIN on income row \u2014 skipped`;
157
+ case "ORPHAN_WITHHOLDING":
158
+ return `Withholding row for ${w.isin} on ${w.date}: no matching income row \u2014 skipped`;
143
159
  }
144
160
  }
145
161
 
@@ -155,6 +171,8 @@ var REQUIRED_COLUMNS = [
155
171
  "Transaction costs currency",
156
172
  "Product"
157
173
  ];
174
+ var INCOME_KEYWORDS = ["DIVIDEND", "COUPON", "INTEREST", "CEDOLA"];
175
+ var TAX_KEYWORDS = ["DIVIDEND TAX", "WITHHOLDING", "RITENUTA"];
158
176
  function parseCSVRow(line) {
159
177
  const fields = [];
160
178
  let i = 0;
@@ -196,6 +214,7 @@ function parseDate(ddmmyyyy) {
196
214
  var DEGIROParser = class {
197
215
  _warningEntries = [];
198
216
  _snapshot;
217
+ _incomeRows = [];
199
218
  constructor(snapshot) {
200
219
  this._snapshot = snapshot;
201
220
  }
@@ -212,6 +231,7 @@ var DEGIROParser = class {
212
231
  */
213
232
  parse(csv) {
214
233
  this._warningEntries = [];
234
+ this._incomeRows = [];
215
235
  if (typeof csv !== "string" || csv.includes("\0")) {
216
236
  throw new ParseError("INVALID_CSV");
217
237
  }
@@ -236,17 +256,70 @@ var DEGIROParser = class {
236
256
  }
237
257
  const snapshot = this._snapshot ?? getActiveSnapshot();
238
258
  const transactions = [];
259
+ const incomeCandidates = [];
260
+ const withholdingCandidates = [];
239
261
  for (let r = 1; r < rows.length; r++) {
240
262
  const row = rows[r];
241
263
  const rowIndex = r + 1;
242
264
  if (row.every((cell) => cell.trim() === "")) continue;
243
265
  const get = (col) => (row[colIndex[col]] ?? "").trim();
244
266
  const isin = get("ISIN");
267
+ const product = get("Product");
268
+ const qtyStr = get("Quantity");
269
+ const rawQty = parseFloat(qtyStr);
270
+ const qtyIsZeroOrBlank = qtyStr === "" || isNaN(rawQty) || rawQty === 0;
271
+ if (qtyIsZeroOrBlank) {
272
+ const upperProduct = product.toUpperCase();
273
+ const isTaxKeyword = TAX_KEYWORDS.some((k) => upperProduct.includes(k));
274
+ const isIncomeKeyword = !isTaxKeyword && INCOME_KEYWORDS.some((k) => upperProduct.includes(k));
275
+ if (isTaxKeyword || isIncomeKeyword) {
276
+ const rawLocalValue = parseFloat(get("Local value"));
277
+ const localValue = isNaN(rawLocalValue) ? 0 : rawLocalValue;
278
+ const isoDate2 = parseDate(get("Date"));
279
+ if (isTaxKeyword && localValue < 0) {
280
+ if (!isin) {
281
+ this._warningEntries.push({
282
+ code: "MISSING_ISIN_INCOME",
283
+ row: rowIndex
284
+ });
285
+ continue;
286
+ }
287
+ withholdingCandidates.push({
288
+ isin,
289
+ date: isoDate2,
290
+ amount: Math.abs(localValue),
291
+ row: rowIndex
292
+ });
293
+ continue;
294
+ }
295
+ if (isIncomeKeyword && localValue > 0) {
296
+ if (!isin) {
297
+ this._warningEntries.push({
298
+ code: "MISSING_ISIN_INCOME",
299
+ row: rowIndex
300
+ });
301
+ continue;
302
+ }
303
+ const incomeType = upperProduct.includes(
304
+ "DIVIDEND"
305
+ ) ? "dividend" : "coupon";
306
+ incomeCandidates.push({
307
+ isin,
308
+ product,
309
+ date: isoDate2,
310
+ incomeType,
311
+ localValue,
312
+ currency: get("Local value currency"),
313
+ row: rowIndex
314
+ });
315
+ continue;
316
+ }
317
+ }
318
+ }
245
319
  if (!isin) {
246
320
  this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
247
321
  continue;
248
322
  }
249
- const rawQty = parseFloat(get("Quantity"));
250
323
  if (isNaN(rawQty) || rawQty === 0) {
251
324
  this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
252
325
  continue;
@@ -287,7 +360,6 @@ var DEGIROParser = class {
287
360
  const rawFees = parseFloat(get("Transaction costs"));
288
361
  const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
289
362
  const pricePerUnit = parseFloat(get("Price"));
290
- const product = get("Product");
291
363
  transactions.push({
292
364
  isin,
293
365
  product,
@@ -302,6 +374,73 @@ var DEGIROParser = class {
302
374
  fxRate
303
375
  });
304
376
  }
377
+ const withholdingByKey = /* @__PURE__ */ new Map();
378
+ for (const w of withholdingCandidates) {
379
+ const key = `${w.isin}|${w.date}`;
380
+ const existing = withholdingByKey.get(key);
381
+ withholdingByKey.set(key, {
382
+ isin: w.isin,
383
+ date: w.date,
384
+ amount: (existing?.amount ?? 0) + w.amount
385
+ });
386
+ }
387
+ const consumedKeys = /* @__PURE__ */ new Set();
388
+ const incomeRows = [];
389
+ for (const c of incomeCandidates) {
390
+ const key = `${c.isin}|${c.date}`;
391
+ const matchedWithholding = withholdingByKey.get(key);
392
+ let grossAmount;
393
+ let withholdingTax;
394
+ let fxRate;
395
+ if (c.currency === "EUR") {
396
+ grossAmount = Math.abs(c.localValue);
397
+ withholdingTax = matchedWithholding?.amount ?? 0;
398
+ fxRate = void 0;
399
+ } else {
400
+ const rate = lookupRate(c.currency, c.date, snapshot);
401
+ if (rate === null) {
402
+ if (snapshot[c.currency] === void 0) {
403
+ this._warningEntries.push({
404
+ code: "UNSUPPORTED_CURRENCY",
405
+ row: c.row,
406
+ currency: c.currency
407
+ });
408
+ } else {
409
+ this._warningEntries.push({
410
+ code: "NO_ECB_RATE",
411
+ row: c.row,
412
+ currency: c.currency,
413
+ date: c.date
414
+ });
415
+ }
416
+ continue;
417
+ }
418
+ grossAmount = Math.abs(c.localValue) / rate;
419
+ withholdingTax = matchedWithholding ? matchedWithholding.amount / rate : 0;
420
+ fxRate = rate;
421
+ }
422
+ if (matchedWithholding) consumedKeys.add(key);
423
+ incomeRows.push({
424
+ isin: c.isin,
425
+ product: c.product,
426
+ date: c.date,
427
+ incomeType: c.incomeType,
428
+ grossAmount,
429
+ withholdingTax,
430
+ currency: c.currency,
431
+ fxRate
432
+ });
433
+ }
434
+ for (const [key, w] of withholdingByKey) {
435
+ if (!consumedKeys.has(key)) {
436
+ this._warningEntries.push({
437
+ code: "ORPHAN_WITHHOLDING",
438
+ isin: w.isin,
439
+ date: w.date
440
+ });
441
+ }
442
+ }
443
+ this._incomeRows = incomeRows;
305
444
  return transactions;
306
445
  }
307
446
  get warnings() {
@@ -310,12 +449,136 @@ var DEGIROParser = class {
310
449
  get warningEntries() {
311
450
  return this._warningEntries;
312
451
  }
452
+ get incomeRows() {
453
+ return this._incomeRows;
454
+ }
313
455
  };
314
456
 
315
- // src/calculator/index.ts
457
+ // src/dichiarazione/engine.ts
458
+ var import_promises = require("fs/promises");
316
459
  function roundHalfUp(x) {
317
460
  return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
318
461
  }
462
+ function buildQuadroRT(bucketB, carryForward, taxYear) {
463
+ const plusvalenze = bucketB.plusvalenze;
464
+ const minusvalenze = bucketB.minusvalenze;
465
+ const differenza = roundHalfUp(plusvalenze - minusvalenze);
466
+ if (differenza > 0) {
467
+ const sorted = [...carryForward].sort((a, b) => a.year - b.year);
468
+ let remaining = differenza;
469
+ const carryForwardApplied = [];
470
+ for (const entry of sorted) {
471
+ if (taxYear - entry.year > 4) continue;
472
+ const consumed = Math.min(entry.amount, remaining);
473
+ if (consumed > 0) {
474
+ carryForwardApplied.push({
475
+ annoOrigine: entry.year,
476
+ importo: roundHalfUp(consumed)
477
+ });
478
+ remaining -= consumed;
479
+ }
480
+ }
481
+ const imponibileNetto = roundHalfUp(remaining);
482
+ const imposta = roundHalfUp(imponibileNetto * 0.26);
483
+ return {
484
+ plusvalenze,
485
+ minusvalenze,
486
+ differenza,
487
+ carryForwardApplied,
488
+ imponibileNetto,
489
+ imposta,
490
+ carryForwardRiportato: []
491
+ };
492
+ } else if (differenza < 0) {
493
+ return {
494
+ plusvalenze,
495
+ minusvalenze,
496
+ differenza,
497
+ carryForwardApplied: [],
498
+ imponibileNetto: 0,
499
+ imposta: 0,
500
+ carryForwardRiportato: [
501
+ { annoOrigine: taxYear, importo: roundHalfUp(Math.abs(differenza)) }
502
+ ]
503
+ };
504
+ } else {
505
+ return {
506
+ plusvalenze,
507
+ minusvalenze,
508
+ differenza: 0,
509
+ carryForwardApplied: [],
510
+ imponibileNetto: 0,
511
+ imposta: 0,
512
+ carryForwardRiportato: []
513
+ };
514
+ }
515
+ }
516
+ function buildQuadroRM(bucketA, incomeRows, taxYear) {
517
+ const groups = bucketA?.groups ?? [];
518
+ const group26 = groups.find((g) => g.taxRate === 0.26);
519
+ const group125 = groups.find((g) => g.taxRate === 0.125);
520
+ const capitaleAliquota26 = {
521
+ plusvalenze: group26?.plusvalenze ?? 0,
522
+ imposta: group26?.imposta ?? 0
523
+ };
524
+ const capitaleAliquota125 = {
525
+ plusvalenze: group125?.plusvalenze ?? 0,
526
+ imposta: group125?.imposta ?? 0
527
+ };
528
+ const dividendiEsteri = incomeRows.filter((r) => r.incomeType === "dividend").map((r) => ({
529
+ isin: r.isin,
530
+ prodotto: r.product,
531
+ lordo: r.grossAmount,
532
+ rittenutaEstera: r.withholdingTax
533
+ }));
534
+ const cedole = incomeRows.filter((r) => r.incomeType === "coupon").map((r) => ({
535
+ isin: r.isin,
536
+ prodotto: r.product,
537
+ importo: r.grossAmount,
538
+ rittenutaEstera: r.withholdingTax
539
+ }));
540
+ return {
541
+ capitaleAliquota26,
542
+ capitaleAliquota125,
543
+ dividendiEsteri,
544
+ cedole
545
+ };
546
+ }
547
+ function buildDichiarazioneReport(quadroRT, quadroRM, taxYear) {
548
+ const version = 1;
549
+ const modello = "Redditi PF";
550
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
551
+ return {
552
+ version,
553
+ annoImposta: taxYear,
554
+ modello,
555
+ generatedAt,
556
+ quadroRT,
557
+ quadroRM,
558
+ exportTo: async (path2) => {
559
+ await (0, import_promises.writeFile)(
560
+ path2,
561
+ JSON.stringify(
562
+ {
563
+ version,
564
+ annoImposta: taxYear,
565
+ modello,
566
+ generatedAt,
567
+ quadroRT,
568
+ quadroRM
569
+ },
570
+ null,
571
+ 2
572
+ )
573
+ );
574
+ }
575
+ };
576
+ }
577
+
578
+ // src/calculator/index.ts
579
+ function roundHalfUp2(x) {
580
+ return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
581
+ }
319
582
  function inferTaxYear(transactions) {
320
583
  const counts = {};
321
584
  for (const t of transactions) {
@@ -333,13 +596,11 @@ function inferTaxYear(transactions) {
333
596
  var Calculator = class {
334
597
  _transactions;
335
598
  _parseWarnings;
336
- /**
337
- * @param transactions - Normalised Transaction objects from the CSV import step.
338
- * @param parseWarnings - Warnings collected during the import step; forwarded into the report.
339
- */
340
- constructor(transactions, parseWarnings) {
599
+ _options;
600
+ constructor(transactions, parseWarnings, options) {
341
601
  this._transactions = transactions;
342
602
  this._parseWarnings = parseWarnings ?? [];
603
+ this._options = options ?? {};
343
604
  }
344
605
  /**
345
606
  * Run LIFO or FIFO lot-matching over the transaction list.
@@ -409,9 +670,9 @@ var Calculator = class {
409
670
  sellDate: tx.date,
410
671
  buyPriceEUR: lot.pricePerUnitEUR,
411
672
  sellPriceEUR: sellPricePerUnitEUR,
412
- buyCostEUR: roundHalfUp(buyCostEUR),
413
- sellProceedsEUR: roundHalfUp(sellProceedsEUR),
414
- gainLossEUR: roundHalfUp(gainLossEUR),
673
+ buyCostEUR: roundHalfUp2(buyCostEUR),
674
+ sellProceedsEUR: roundHalfUp2(sellProceedsEUR),
675
+ gainLossEUR: roundHalfUp2(gainLossEUR),
415
676
  buyFxRate: lot.fxRate,
416
677
  sellFxRate: tx.fxRate
417
678
  });
@@ -433,12 +694,133 @@ var Calculator = class {
433
694
  if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
434
695
  else minusvalenze += Math.abs(lot.gainLossEUR);
435
696
  }
697
+ if (this._options.classification) {
698
+ const classification = this._options.classification;
699
+ const unclassifiedIsins = /* @__PURE__ */ new Set();
700
+ for (const lot of matchedLots) {
701
+ const entry = classification[lot.isin];
702
+ if (!entry) {
703
+ lot.bucket = "B";
704
+ unclassifiedIsins.add(lot.isin);
705
+ } else if (entry.bucketGain === "A" && lot.gainLossEUR >= 0) {
706
+ lot.bucket = "A";
707
+ } else {
708
+ lot.bucket = "B";
709
+ }
710
+ }
711
+ for (const isin of unclassifiedIsins) {
712
+ warnings.push(
713
+ `ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`
714
+ );
715
+ }
716
+ const bucketALots = matchedLots.filter((l) => l.bucket === "A");
717
+ const groupsByRate = /* @__PURE__ */ new Map();
718
+ for (const lot of bucketALots) {
719
+ const entry = classification[lot.isin];
720
+ const rate = entry.taxRate;
721
+ if (!groupsByRate.has(rate))
722
+ groupsByRate.set(rate, { assetClasses: /* @__PURE__ */ new Set(), plusvalenze: 0 });
723
+ const g = groupsByRate.get(rate);
724
+ g.assetClasses.add(entry.assetClass);
725
+ g.plusvalenze += lot.gainLossEUR;
726
+ }
727
+ const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
728
+ taxRate,
729
+ assetClasses: [...g.assetClasses],
730
+ plusvalenze: roundHalfUp2(g.plusvalenze),
731
+ imposta: roundHalfUp2(g.plusvalenze * taxRate)
732
+ }));
733
+ const bucketAReport = {
734
+ groups: bucketAGroups,
735
+ totalImposta: roundHalfUp2(
736
+ bucketAGroups.reduce((s, g) => s + g.imposta, 0)
737
+ )
738
+ };
739
+ const bucketBLots = matchedLots.filter((l) => l.bucket === "B");
740
+ let bPlusvalenze = 0;
741
+ let bMinusvalenze = 0;
742
+ for (const lot of bucketBLots) {
743
+ if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
744
+ else bMinusvalenze += Math.abs(lot.gainLossEUR);
745
+ }
746
+ bPlusvalenze = roundHalfUp2(bPlusvalenze);
747
+ bMinusvalenze = roundHalfUp2(bMinusvalenze);
748
+ const carryForwards = [...this._options.carryForward ?? []].sort(
749
+ (a, b) => a.year - b.year
750
+ );
751
+ let remaining = bPlusvalenze - bMinusvalenze;
752
+ let carryForwardApplied = 0;
753
+ for (const entry of carryForwards) {
754
+ if (taxYear - entry.year > 4) continue;
755
+ if (remaining <= 0) break;
756
+ const consumed = Math.min(entry.amount, remaining);
757
+ carryForwardApplied += consumed;
758
+ remaining -= consumed;
759
+ }
760
+ carryForwardApplied = roundHalfUp2(carryForwardApplied);
761
+ const bNetResult = roundHalfUp2(
762
+ bPlusvalenze - bMinusvalenze - carryForwardApplied
763
+ );
764
+ const carryForwardRemaining = roundHalfUp2(Math.max(0, -bNetResult));
765
+ const bucketBReport = {
766
+ plusvalenze: bPlusvalenze,
767
+ minusvalenze: bMinusvalenze,
768
+ carryForwardApplied,
769
+ carryForwardRemaining,
770
+ netResult: bNetResult
771
+ };
772
+ const allIncomeRows = this._options.incomeRows ?? [];
773
+ const filteredIncomeRows = allIncomeRows.filter(
774
+ (row) => new Date(row.date).getFullYear() === taxYear
775
+ );
776
+ if (filteredIncomeRows.length < allIncomeRows.length) {
777
+ warnings.push(`Income rows outside tax year ${taxYear} were skipped.`);
778
+ }
779
+ const quadroRT = buildQuadroRT(
780
+ bucketBReport,
781
+ this._options.carryForward ?? [],
782
+ taxYear
783
+ );
784
+ const quadroRM = buildQuadroRM(
785
+ bucketAGroups.length > 0 ? bucketAReport : void 0,
786
+ filteredIncomeRows,
787
+ taxYear
788
+ );
789
+ const dichiarazioneReport = buildDichiarazioneReport(
790
+ quadroRT,
791
+ quadroRM,
792
+ taxYear
793
+ );
794
+ return {
795
+ method,
796
+ taxYear,
797
+ plusvalenze: roundHalfUp2(plusvalenze),
798
+ minusvalenze: roundHalfUp2(minusvalenze),
799
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
800
+ lots: matchedLots,
801
+ ratesUsed,
802
+ warnings,
803
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
804
+ bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
805
+ bucketB: bucketBReport,
806
+ dichiarazione: dichiarazioneReport
807
+ };
808
+ } else {
809
+ const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
810
+ const hasIePrefix = allIsins.some((isin) => isin.startsWith("IE"));
811
+ const hasOther = allIsins.some((isin) => !isin.startsWith("IE"));
812
+ if (hasIePrefix && hasOther) {
813
+ warnings.push(
814
+ "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"
815
+ );
816
+ }
817
+ }
436
818
  return {
437
819
  method,
438
820
  taxYear,
439
- plusvalenze: roundHalfUp(plusvalenze),
440
- minusvalenze: roundHalfUp(minusvalenze),
441
- netResult: roundHalfUp(plusvalenze - minusvalenze),
821
+ plusvalenze: roundHalfUp2(plusvalenze),
822
+ minusvalenze: roundHalfUp2(minusvalenze),
823
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
442
824
  lots: matchedLots,
443
825
  ratesUsed,
444
826
  warnings,
@@ -446,10 +828,358 @@ var Calculator = class {
446
828
  };
447
829
  }
448
830
  };
831
+
832
+ // src/classifier/index.ts
833
+ var fs2 = __toESM(require("fs"), 1);
834
+ var https = __toESM(require("https"), 1);
835
+ function httpsPost(url, body, timeoutMs) {
836
+ return new Promise((resolve, reject) => {
837
+ const u = new URL(url);
838
+ const req = https.request(
839
+ {
840
+ hostname: u.hostname,
841
+ path: u.pathname + u.search,
842
+ method: "POST",
843
+ headers: {
844
+ "Content-Type": "application/json",
845
+ "Content-Length": Buffer.byteLength(body)
846
+ }
847
+ },
848
+ (res) => {
849
+ let data = "";
850
+ res.on("data", (chunk) => {
851
+ data += chunk;
852
+ });
853
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, data }));
854
+ }
855
+ );
856
+ req.setTimeout(timeoutMs, () => {
857
+ req.destroy();
858
+ reject(new Error("timeout"));
859
+ });
860
+ req.on("error", reject);
861
+ req.write(body);
862
+ req.end();
863
+ });
864
+ }
865
+ var GOVT_BOND_WHITELIST = /* @__PURE__ */ new Set([
866
+ "IT",
867
+ "DE",
868
+ "FR",
869
+ "AT",
870
+ "BE",
871
+ "NL",
872
+ "ES",
873
+ "PT",
874
+ "FI",
875
+ "IE",
876
+ "LU",
877
+ "GR",
878
+ "SK",
879
+ "SI",
880
+ "LT",
881
+ "LV",
882
+ "EE",
883
+ "MT",
884
+ "CY",
885
+ "US",
886
+ "GB",
887
+ "CH",
888
+ "NO",
889
+ "SE",
890
+ "DK",
891
+ "JP",
892
+ "CA",
893
+ "AU",
894
+ "NZ",
895
+ "SG",
896
+ "HK",
897
+ "KR"
898
+ ]);
899
+ var SECURITY_TYPE_MAP = {
900
+ ETP: {
901
+ assetClass: "ETF",
902
+ bucketGain: "A",
903
+ bucketLoss: "B",
904
+ taxRate: 0.26,
905
+ whiteListed: null
906
+ },
907
+ ETF: {
908
+ assetClass: "ETF",
909
+ bucketGain: "A",
910
+ bucketLoss: "B",
911
+ taxRate: 0.26,
912
+ whiteListed: null
913
+ },
914
+ "Mutual Fund": {
915
+ assetClass: "ETF",
916
+ bucketGain: "A",
917
+ bucketLoss: "B",
918
+ taxRate: 0.26,
919
+ whiteListed: null
920
+ },
921
+ "Open-End Fund": {
922
+ assetClass: "ETF",
923
+ bucketGain: "A",
924
+ bucketLoss: "B",
925
+ taxRate: 0.26,
926
+ whiteListed: null
927
+ },
928
+ "Common Stock": {
929
+ assetClass: "Stock",
930
+ bucketGain: "B",
931
+ bucketLoss: "B",
932
+ taxRate: 0,
933
+ whiteListed: null
934
+ },
935
+ Equity: {
936
+ assetClass: "Stock",
937
+ bucketGain: "B",
938
+ bucketLoss: "B",
939
+ taxRate: 0,
940
+ whiteListed: null
941
+ },
942
+ "Exchange Traded Commodity": {
943
+ assetClass: "ETC",
944
+ bucketGain: "B",
945
+ bucketLoss: "B",
946
+ taxRate: 0,
947
+ whiteListed: null
948
+ },
949
+ "Corporate Bond": {
950
+ assetClass: "CorpBond",
951
+ bucketGain: "B",
952
+ bucketLoss: "B",
953
+ taxRate: 0,
954
+ whiteListed: null
955
+ },
956
+ Option: {
957
+ assetClass: "Derivative",
958
+ bucketGain: "B",
959
+ bucketLoss: "B",
960
+ taxRate: 0,
961
+ whiteListed: null
962
+ },
963
+ Future: {
964
+ assetClass: "Derivative",
965
+ bucketGain: "B",
966
+ bucketLoss: "B",
967
+ taxRate: 0,
968
+ whiteListed: null
969
+ },
970
+ Warrant: {
971
+ assetClass: "Derivative",
972
+ bucketGain: "B",
973
+ bucketLoss: "B",
974
+ taxRate: 0,
975
+ whiteListed: null
976
+ },
977
+ CFD: {
978
+ assetClass: "Derivative",
979
+ bucketGain: "B",
980
+ bucketLoss: "B",
981
+ taxRate: 0,
982
+ whiteListed: null
983
+ },
984
+ Swap: {
985
+ assetClass: "Derivative",
986
+ bucketGain: "B",
987
+ bucketLoss: "B",
988
+ taxRate: 0,
989
+ whiteListed: null
990
+ },
991
+ "Leverage Certificate": {
992
+ assetClass: "LeverageCert",
993
+ bucketGain: "B",
994
+ bucketLoss: "B",
995
+ taxRate: 0,
996
+ whiteListed: null
997
+ },
998
+ "Turbo Certificate": {
999
+ assetClass: "LeverageCert",
1000
+ bucketGain: "B",
1001
+ bucketLoss: "B",
1002
+ taxRate: 0,
1003
+ whiteListed: null
1004
+ },
1005
+ "Capital Protected Certificate": {
1006
+ assetClass: "CapProtectedCert",
1007
+ bucketGain: "A",
1008
+ bucketLoss: "B",
1009
+ taxRate: 0.26,
1010
+ whiteListed: null
1011
+ }
1012
+ };
1013
+ function isKnownType(t) {
1014
+ return t === "Government Bond" || t in SECURITY_TYPE_MAP;
1015
+ }
1016
+ function classifyByType(isin, securityType, product, warnings) {
1017
+ if (securityType === "Government Bond") {
1018
+ const prefix = isin.slice(0, 2).toUpperCase();
1019
+ const whiteListed = GOVT_BOND_WHITELIST.has(prefix);
1020
+ return {
1021
+ product,
1022
+ assetClass: whiteListed ? "GovtBondWL" : "GovtBondOther",
1023
+ bucketGain: "A",
1024
+ bucketLoss: "B",
1025
+ taxRate: whiteListed ? 0.125 : 0.26,
1026
+ whiteListed,
1027
+ confirmedByUser: false,
1028
+ source: "openfigi"
1029
+ };
1030
+ }
1031
+ const mapped = SECURITY_TYPE_MAP[securityType];
1032
+ if (mapped) {
1033
+ return {
1034
+ product,
1035
+ assetClass: mapped.assetClass,
1036
+ bucketGain: mapped.bucketGain,
1037
+ bucketLoss: mapped.bucketLoss,
1038
+ taxRate: mapped.taxRate,
1039
+ whiteListed: mapped.whiteListed,
1040
+ confirmedByUser: false,
1041
+ source: "openfigi"
1042
+ };
1043
+ }
1044
+ warnings.push(
1045
+ `Unrecognized type: ${securityType}. Please classify manually.`
1046
+ );
1047
+ return {
1048
+ product,
1049
+ assetClass: "Stock",
1050
+ bucketGain: "B",
1051
+ bucketLoss: "B",
1052
+ taxRate: 0,
1053
+ whiteListed: null,
1054
+ confirmedByUser: false,
1055
+ source: "user"
1056
+ };
1057
+ }
1058
+ var Classifier = class {
1059
+ interactive;
1060
+ constructor(options) {
1061
+ this.interactive = options?.interactive ?? true;
1062
+ }
1063
+ async load(sidecarPath) {
1064
+ if (!fs2.existsSync(sidecarPath)) {
1065
+ throw new ClassificationError("SIDECAR_NOT_FOUND");
1066
+ }
1067
+ let parsed;
1068
+ try {
1069
+ const raw = fs2.readFileSync(sidecarPath, "utf-8");
1070
+ parsed = JSON.parse(raw);
1071
+ } catch {
1072
+ throw new ClassificationError("SIDECAR_MALFORMED");
1073
+ }
1074
+ if (typeof parsed !== "object" || parsed === null || parsed["version"] !== 1) {
1075
+ throw new ClassificationError("SIDECAR_VERSION");
1076
+ }
1077
+ return parsed.classifications;
1078
+ }
1079
+ async classify(transactions, sidecarPath, _httpPost = httpsPost) {
1080
+ const isinToProduct = /* @__PURE__ */ new Map();
1081
+ for (const tx of transactions) {
1082
+ if (tx.isin && !isinToProduct.has(tx.isin)) {
1083
+ isinToProduct.set(tx.isin, tx.product);
1084
+ }
1085
+ }
1086
+ const confirmed = {};
1087
+ if (fs2.existsSync(sidecarPath)) {
1088
+ const existingMap = await this.load(sidecarPath);
1089
+ for (const [isin, entry] of Object.entries(existingMap)) {
1090
+ if (entry.confirmedByUser) {
1091
+ confirmed[isin] = entry;
1092
+ }
1093
+ }
1094
+ }
1095
+ const toProcess = [];
1096
+ for (const isin of isinToProduct.keys()) {
1097
+ if (!(isin in confirmed)) {
1098
+ toProcess.push(isin);
1099
+ }
1100
+ }
1101
+ const warnings = [];
1102
+ const newEntries = {};
1103
+ const BATCH_SIZE = 10;
1104
+ for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
1105
+ if (i > 0) {
1106
+ await new Promise((resolve) => setTimeout(resolve, 6e3));
1107
+ }
1108
+ const batch = toProcess.slice(i, i + BATCH_SIZE);
1109
+ const requestBody = JSON.stringify(
1110
+ batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
1111
+ );
1112
+ let response = null;
1113
+ for (let attempt = 0; attempt < 2; attempt++) {
1114
+ try {
1115
+ const r = await _httpPost(
1116
+ "https://api.openfigi.com/v3/mapping",
1117
+ requestBody,
1118
+ 1e4
1119
+ );
1120
+ if (r.status < 500) {
1121
+ response = r;
1122
+ break;
1123
+ }
1124
+ } catch {
1125
+ throw new ClassificationError("NETWORK_ERROR");
1126
+ }
1127
+ }
1128
+ if (response === null) {
1129
+ throw new ClassificationError("NETWORK_ERROR");
1130
+ }
1131
+ const results = JSON.parse(response.data);
1132
+ for (let j = 0; j < batch.length; j++) {
1133
+ const isin = batch[j];
1134
+ const result = results[j];
1135
+ const product = isinToProduct.get(isin) ?? isin;
1136
+ if (!result || result.error || !result.data || result.data.length === 0) {
1137
+ warnings.push(
1138
+ `Unrecognized type: unknown. Please classify manually.`
1139
+ );
1140
+ newEntries[isin] = {
1141
+ product,
1142
+ assetClass: "Stock",
1143
+ bucketGain: "B",
1144
+ bucketLoss: "B",
1145
+ taxRate: 0,
1146
+ whiteListed: null,
1147
+ confirmedByUser: false,
1148
+ source: "user"
1149
+ };
1150
+ continue;
1151
+ }
1152
+ const st = result.data[0]?.securityType;
1153
+ const st2 = result.data[0]?.securityType2;
1154
+ const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
1155
+ newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
1156
+ }
1157
+ }
1158
+ const mergedMap = { ...newEntries, ...confirmed };
1159
+ const sidecarContent = JSON.stringify(
1160
+ {
1161
+ version: 1,
1162
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1163
+ classifications: mergedMap,
1164
+ warnings
1165
+ },
1166
+ null,
1167
+ 2
1168
+ );
1169
+ try {
1170
+ fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
1171
+ } catch {
1172
+ throw new ClassificationError("WRITE_ERROR");
1173
+ }
1174
+ return mergedMap;
1175
+ }
1176
+ };
449
1177
  // Annotate the CommonJS export names for ESM import in node:
450
1178
  0 && (module.exports = {
451
1179
  CalculationError,
452
1180
  Calculator,
1181
+ ClassificationError,
1182
+ Classifier,
453
1183
  DEGIROParser,
454
1184
  ParseError
455
1185
  });