@gabrielerandelli/minus-tracker 0.6.0 → 0.8.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
@@ -152,6 +152,10 @@ function warningToEnglish(w) {
152
152
  return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
153
153
  case "QUANTITY_ZERO":
154
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`;
155
159
  }
156
160
  }
157
161
 
@@ -167,6 +171,8 @@ var REQUIRED_COLUMNS = [
167
171
  "Transaction costs currency",
168
172
  "Product"
169
173
  ];
174
+ var INCOME_KEYWORDS = ["DIVIDEND", "COUPON", "INTEREST", "CEDOLA"];
175
+ var TAX_KEYWORDS = ["DIVIDEND TAX", "WITHHOLDING", "RITENUTA"];
170
176
  function parseCSVRow(line) {
171
177
  const fields = [];
172
178
  let i = 0;
@@ -208,6 +214,7 @@ function parseDate(ddmmyyyy) {
208
214
  var DEGIROParser = class {
209
215
  _warningEntries = [];
210
216
  _snapshot;
217
+ _incomeRows = [];
211
218
  constructor(snapshot) {
212
219
  this._snapshot = snapshot;
213
220
  }
@@ -224,6 +231,7 @@ var DEGIROParser = class {
224
231
  */
225
232
  parse(csv) {
226
233
  this._warningEntries = [];
234
+ this._incomeRows = [];
227
235
  if (typeof csv !== "string" || csv.includes("\0")) {
228
236
  throw new ParseError("INVALID_CSV");
229
237
  }
@@ -248,17 +256,70 @@ var DEGIROParser = class {
248
256
  }
249
257
  const snapshot = this._snapshot ?? getActiveSnapshot();
250
258
  const transactions = [];
259
+ const incomeCandidates = [];
260
+ const withholdingCandidates = [];
251
261
  for (let r = 1; r < rows.length; r++) {
252
262
  const row = rows[r];
253
263
  const rowIndex = r + 1;
254
264
  if (row.every((cell) => cell.trim() === "")) continue;
255
265
  const get = (col) => (row[colIndex[col]] ?? "").trim();
256
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
+ }
257
319
  if (!isin) {
258
320
  this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
259
321
  continue;
260
322
  }
261
- const rawQty = parseFloat(get("Quantity"));
262
323
  if (isNaN(rawQty) || rawQty === 0) {
263
324
  this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
264
325
  continue;
@@ -299,7 +360,6 @@ var DEGIROParser = class {
299
360
  const rawFees = parseFloat(get("Transaction costs"));
300
361
  const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
301
362
  const pricePerUnit = parseFloat(get("Price"));
302
- const product = get("Product");
303
363
  transactions.push({
304
364
  isin,
305
365
  product,
@@ -314,6 +374,73 @@ var DEGIROParser = class {
314
374
  fxRate
315
375
  });
316
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;
317
444
  return transactions;
318
445
  }
319
446
  get warnings() {
@@ -322,12 +449,136 @@ var DEGIROParser = class {
322
449
  get warningEntries() {
323
450
  return this._warningEntries;
324
451
  }
452
+ get incomeRows() {
453
+ return this._incomeRows;
454
+ }
325
455
  };
326
456
 
327
- // src/calculator/index.ts
457
+ // src/dichiarazione/engine.ts
458
+ var import_promises = require("fs/promises");
328
459
  function roundHalfUp(x) {
329
460
  return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
330
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
+ }
331
582
  function inferTaxYear(transactions) {
332
583
  const counts = {};
333
584
  for (const t of transactions) {
@@ -419,9 +670,9 @@ var Calculator = class {
419
670
  sellDate: tx.date,
420
671
  buyPriceEUR: lot.pricePerUnitEUR,
421
672
  sellPriceEUR: sellPricePerUnitEUR,
422
- buyCostEUR: roundHalfUp(buyCostEUR),
423
- sellProceedsEUR: roundHalfUp(sellProceedsEUR),
424
- gainLossEUR: roundHalfUp(gainLossEUR),
673
+ buyCostEUR: roundHalfUp2(buyCostEUR),
674
+ sellProceedsEUR: roundHalfUp2(sellProceedsEUR),
675
+ gainLossEUR: roundHalfUp2(gainLossEUR),
425
676
  buyFxRate: lot.fxRate,
426
677
  sellFxRate: tx.fxRate
427
678
  });
@@ -476,12 +727,12 @@ var Calculator = class {
476
727
  const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
477
728
  taxRate,
478
729
  assetClasses: [...g.assetClasses],
479
- plusvalenze: roundHalfUp(g.plusvalenze),
480
- imposta: roundHalfUp(g.plusvalenze * taxRate)
730
+ plusvalenze: roundHalfUp2(g.plusvalenze),
731
+ imposta: roundHalfUp2(g.plusvalenze * taxRate)
481
732
  }));
482
733
  const bucketAReport = {
483
734
  groups: bucketAGroups,
484
- totalImposta: roundHalfUp(
735
+ totalImposta: roundHalfUp2(
485
736
  bucketAGroups.reduce((s, g) => s + g.imposta, 0)
486
737
  )
487
738
  };
@@ -492,8 +743,8 @@ var Calculator = class {
492
743
  if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
493
744
  else bMinusvalenze += Math.abs(lot.gainLossEUR);
494
745
  }
495
- bPlusvalenze = roundHalfUp(bPlusvalenze);
496
- bMinusvalenze = roundHalfUp(bMinusvalenze);
746
+ bPlusvalenze = roundHalfUp2(bPlusvalenze);
747
+ bMinusvalenze = roundHalfUp2(bMinusvalenze);
497
748
  const carryForwards = [...this._options.carryForward ?? []].sort(
498
749
  (a, b) => a.year - b.year
499
750
  );
@@ -506,11 +757,11 @@ var Calculator = class {
506
757
  carryForwardApplied += consumed;
507
758
  remaining -= consumed;
508
759
  }
509
- carryForwardApplied = roundHalfUp(carryForwardApplied);
510
- const bNetResult = roundHalfUp(
760
+ carryForwardApplied = roundHalfUp2(carryForwardApplied);
761
+ const bNetResult = roundHalfUp2(
511
762
  bPlusvalenze - bMinusvalenze - carryForwardApplied
512
763
  );
513
- const carryForwardRemaining = roundHalfUp(Math.max(0, -bNetResult));
764
+ const carryForwardRemaining = roundHalfUp2(Math.max(0, -bNetResult));
514
765
  const bucketBReport = {
515
766
  plusvalenze: bPlusvalenze,
516
767
  minusvalenze: bMinusvalenze,
@@ -518,18 +769,41 @@ var Calculator = class {
518
769
  carryForwardRemaining,
519
770
  netResult: bNetResult
520
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
+ );
521
794
  return {
522
795
  method,
523
796
  taxYear,
524
- plusvalenze: roundHalfUp(plusvalenze),
525
- minusvalenze: roundHalfUp(minusvalenze),
526
- netResult: roundHalfUp(plusvalenze - minusvalenze),
797
+ plusvalenze: roundHalfUp2(plusvalenze),
798
+ minusvalenze: roundHalfUp2(minusvalenze),
799
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
527
800
  lots: matchedLots,
528
801
  ratesUsed,
529
802
  warnings,
530
803
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
531
804
  bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
532
- bucketB: bucketBReport
805
+ bucketB: bucketBReport,
806
+ dichiarazione: dichiarazioneReport
533
807
  };
534
808
  } else {
535
809
  const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
@@ -544,9 +818,9 @@ var Calculator = class {
544
818
  return {
545
819
  method,
546
820
  taxYear,
547
- plusvalenze: roundHalfUp(plusvalenze),
548
- minusvalenze: roundHalfUp(minusvalenze),
549
- netResult: roundHalfUp(plusvalenze - minusvalenze),
821
+ plusvalenze: roundHalfUp2(plusvalenze),
822
+ minusvalenze: roundHalfUp2(minusvalenze),
823
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
550
824
  lots: matchedLots,
551
825
  ratesUsed,
552
826
  warnings,
@@ -768,7 +1042,7 @@ function classifyByType(isin, securityType, product, warnings) {
768
1042
  };
769
1043
  }
770
1044
  warnings.push(
771
- `Unrecognized type: ${securityType}. Please classify manually.`
1045
+ `Unrecognized type for ${isin}: ${securityType}. Please classify manually.`
772
1046
  );
773
1047
  return {
774
1048
  product,
@@ -781,6 +1055,84 @@ function classifyByType(isin, securityType, product, warnings) {
781
1055
  source: "user"
782
1056
  };
783
1057
  }
1058
+ var ASSET_CLASS_DEFAULTS = {
1059
+ ETF: {
1060
+ assetClass: "ETF",
1061
+ bucketGain: "A",
1062
+ bucketLoss: "B",
1063
+ taxRate: 0.26,
1064
+ whiteListed: null
1065
+ },
1066
+ Stock: {
1067
+ assetClass: "Stock",
1068
+ bucketGain: "B",
1069
+ bucketLoss: "B",
1070
+ taxRate: 0,
1071
+ whiteListed: null
1072
+ },
1073
+ ETC: {
1074
+ assetClass: "ETC",
1075
+ bucketGain: "B",
1076
+ bucketLoss: "B",
1077
+ taxRate: 0,
1078
+ whiteListed: null
1079
+ },
1080
+ GovtBondWL: {
1081
+ assetClass: "GovtBondWL",
1082
+ bucketGain: "A",
1083
+ bucketLoss: "B",
1084
+ taxRate: 0.125,
1085
+ whiteListed: true
1086
+ },
1087
+ GovtBondOther: {
1088
+ assetClass: "GovtBondOther",
1089
+ bucketGain: "A",
1090
+ bucketLoss: "B",
1091
+ taxRate: 0.26,
1092
+ whiteListed: false
1093
+ },
1094
+ CorpBond: {
1095
+ assetClass: "CorpBond",
1096
+ bucketGain: "B",
1097
+ bucketLoss: "B",
1098
+ taxRate: 0,
1099
+ whiteListed: null
1100
+ },
1101
+ Derivative: {
1102
+ assetClass: "Derivative",
1103
+ bucketGain: "B",
1104
+ bucketLoss: "B",
1105
+ taxRate: 0,
1106
+ whiteListed: null
1107
+ },
1108
+ LeverageCert: {
1109
+ assetClass: "LeverageCert",
1110
+ bucketGain: "B",
1111
+ bucketLoss: "B",
1112
+ taxRate: 0,
1113
+ whiteListed: null
1114
+ },
1115
+ CapProtectedCert: {
1116
+ assetClass: "CapProtectedCert",
1117
+ bucketGain: "A",
1118
+ bucketLoss: "B",
1119
+ taxRate: 0.26,
1120
+ whiteListed: null
1121
+ }
1122
+ };
1123
+ function buildEntryFromAssetClass(assetClass, product) {
1124
+ const mapped = ASSET_CLASS_DEFAULTS[assetClass];
1125
+ return {
1126
+ product,
1127
+ assetClass: mapped.assetClass,
1128
+ bucketGain: mapped.bucketGain,
1129
+ bucketLoss: mapped.bucketLoss,
1130
+ taxRate: mapped.taxRate,
1131
+ whiteListed: mapped.whiteListed,
1132
+ confirmedByUser: true,
1133
+ source: "user"
1134
+ };
1135
+ }
784
1136
  var Classifier = class {
785
1137
  interactive;
786
1138
  constructor(options) {
@@ -802,7 +1154,7 @@ var Classifier = class {
802
1154
  }
803
1155
  return parsed.classifications;
804
1156
  }
805
- async classify(transactions, sidecarPath, _httpPost = httpsPost) {
1157
+ async classify(transactions, sidecarPath, options, _httpPost = httpsPost) {
806
1158
  const isinToProduct = /* @__PURE__ */ new Map();
807
1159
  for (const tx of transactions) {
808
1160
  if (tx.isin && !isinToProduct.has(tx.isin)) {
@@ -810,13 +1162,21 @@ var Classifier = class {
810
1162
  }
811
1163
  }
812
1164
  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;
1165
+ if (sidecarPath !== void 0) {
1166
+ if (fs2.existsSync(sidecarPath)) {
1167
+ const existingMap = await this.load(sidecarPath);
1168
+ for (const [isin, entry] of Object.entries(existingMap)) {
1169
+ if (entry.confirmedByUser) {
1170
+ confirmed[isin] = entry;
1171
+ }
818
1172
  }
819
1173
  }
1174
+ } else if (options?.existingClassification) {
1175
+ Object.assign(confirmed, options.existingClassification);
1176
+ }
1177
+ for (const [isin, assetClass] of Object.entries(options?.overrides ?? {})) {
1178
+ const product = isinToProduct.get(isin) ?? isin;
1179
+ confirmed[isin] = buildEntryFromAssetClass(assetClass, product);
820
1180
  }
821
1181
  const toProcess = [];
822
1182
  for (const isin of isinToProduct.keys()) {
@@ -826,76 +1186,100 @@ var Classifier = class {
826
1186
  }
827
1187
  const warnings = [];
828
1188
  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));
1189
+ if (options?.offline) {
1190
+ for (const isin of toProcess) {
1191
+ const product = isinToProduct.get(isin) ?? isin;
1192
+ warnings.push(
1193
+ `Unrecognized type for ${isin}: unknown. Please classify manually.`
1194
+ );
1195
+ newEntries[isin] = {
1196
+ product,
1197
+ assetClass: "Stock",
1198
+ bucketGain: "B",
1199
+ bucketLoss: "B",
1200
+ taxRate: 0,
1201
+ whiteListed: null,
1202
+ confirmedByUser: false,
1203
+ source: "user"
1204
+ };
833
1205
  }
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;
1206
+ } else {
1207
+ const BATCH_SIZE = 10;
1208
+ const totalBatches = Math.ceil(toProcess.length / BATCH_SIZE);
1209
+ for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
1210
+ if (i > 0) {
1211
+ await new Promise((resolve) => setTimeout(resolve, 6e3));
1212
+ }
1213
+ const batch = toProcess.slice(i, i + BATCH_SIZE);
1214
+ const requestBody = JSON.stringify(
1215
+ batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
1216
+ );
1217
+ let response = null;
1218
+ for (let attempt = 0; attempt < 2; attempt++) {
1219
+ try {
1220
+ const r = await _httpPost(
1221
+ "https://api.openfigi.com/v3/mapping",
1222
+ requestBody,
1223
+ 1e4
1224
+ );
1225
+ if (r.status < 500) {
1226
+ response = r;
1227
+ break;
1228
+ }
1229
+ } catch {
1230
+ throw new ClassificationError("NETWORK_ERROR");
849
1231
  }
850
- } catch {
1232
+ }
1233
+ if (response === null) {
851
1234
  throw new ClassificationError("NETWORK_ERROR");
852
1235
  }
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;
1236
+ const results = JSON.parse(response.data);
1237
+ for (let j = 0; j < batch.length; j++) {
1238
+ const isin = batch[j];
1239
+ const result = results[j];
1240
+ const product = isinToProduct.get(isin) ?? isin;
1241
+ if (!result || result.error || !result.data || result.data.length === 0) {
1242
+ warnings.push(
1243
+ `Unrecognized type for ${isin}: unknown. Please classify manually.`
1244
+ );
1245
+ newEntries[isin] = {
1246
+ product,
1247
+ assetClass: "Stock",
1248
+ bucketGain: "B",
1249
+ bucketLoss: "B",
1250
+ taxRate: 0,
1251
+ whiteListed: null,
1252
+ confirmedByUser: false,
1253
+ source: "user"
1254
+ };
1255
+ continue;
1256
+ }
1257
+ const st = result.data[0]?.securityType;
1258
+ const st2 = result.data[0]?.securityType2;
1259
+ const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
1260
+ newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
877
1261
  }
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);
1262
+ const batchIndex = i / BATCH_SIZE;
1263
+ options?.onBatchProgress?.(batchIndex + 1, totalBatches);
882
1264
  }
883
1265
  }
884
1266
  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");
1267
+ if (sidecarPath !== void 0) {
1268
+ const sidecarContent = JSON.stringify(
1269
+ {
1270
+ version: 1,
1271
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1272
+ classifications: mergedMap,
1273
+ warnings
1274
+ },
1275
+ null,
1276
+ 2
1277
+ );
1278
+ try {
1279
+ fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
1280
+ } catch {
1281
+ throw new ClassificationError("WRITE_ERROR");
1282
+ }
899
1283
  }
900
1284
  return mergedMap;
901
1285
  }