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