@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.js CHANGED
@@ -110,6 +110,10 @@ function warningToEnglish(w) {
110
110
  return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
111
111
  case "QUANTITY_ZERO":
112
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`;
113
117
  }
114
118
  }
115
119
 
@@ -125,6 +129,8 @@ var REQUIRED_COLUMNS = [
125
129
  "Transaction costs currency",
126
130
  "Product"
127
131
  ];
132
+ var INCOME_KEYWORDS = ["DIVIDEND", "COUPON", "INTEREST", "CEDOLA"];
133
+ var TAX_KEYWORDS = ["DIVIDEND TAX", "WITHHOLDING", "RITENUTA"];
128
134
  function parseCSVRow(line) {
129
135
  const fields = [];
130
136
  let i = 0;
@@ -166,6 +172,7 @@ function parseDate(ddmmyyyy) {
166
172
  var DEGIROParser = class {
167
173
  _warningEntries = [];
168
174
  _snapshot;
175
+ _incomeRows = [];
169
176
  constructor(snapshot) {
170
177
  this._snapshot = snapshot;
171
178
  }
@@ -182,6 +189,7 @@ var DEGIROParser = class {
182
189
  */
183
190
  parse(csv) {
184
191
  this._warningEntries = [];
192
+ this._incomeRows = [];
185
193
  if (typeof csv !== "string" || csv.includes("\0")) {
186
194
  throw new ParseError("INVALID_CSV");
187
195
  }
@@ -206,17 +214,70 @@ var DEGIROParser = class {
206
214
  }
207
215
  const snapshot = this._snapshot ?? getActiveSnapshot();
208
216
  const transactions = [];
217
+ const incomeCandidates = [];
218
+ const withholdingCandidates = [];
209
219
  for (let r = 1; r < rows.length; r++) {
210
220
  const row = rows[r];
211
221
  const rowIndex = r + 1;
212
222
  if (row.every((cell) => cell.trim() === "")) continue;
213
223
  const get = (col) => (row[colIndex[col]] ?? "").trim();
214
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
+ }
215
277
  if (!isin) {
216
278
  this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
217
279
  continue;
218
280
  }
219
- const rawQty = parseFloat(get("Quantity"));
220
281
  if (isNaN(rawQty) || rawQty === 0) {
221
282
  this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
222
283
  continue;
@@ -257,7 +318,6 @@ var DEGIROParser = class {
257
318
  const rawFees = parseFloat(get("Transaction costs"));
258
319
  const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
259
320
  const pricePerUnit = parseFloat(get("Price"));
260
- const product = get("Product");
261
321
  transactions.push({
262
322
  isin,
263
323
  product,
@@ -272,6 +332,73 @@ var DEGIROParser = class {
272
332
  fxRate
273
333
  });
274
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;
275
402
  return transactions;
276
403
  }
277
404
  get warnings() {
@@ -280,12 +407,136 @@ var DEGIROParser = class {
280
407
  get warningEntries() {
281
408
  return this._warningEntries;
282
409
  }
410
+ get incomeRows() {
411
+ return this._incomeRows;
412
+ }
283
413
  };
284
414
 
285
- // src/calculator/index.ts
415
+ // src/dichiarazione/engine.ts
416
+ import { writeFile } from "fs/promises";
286
417
  function roundHalfUp(x) {
287
418
  return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
288
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
+ }
289
540
  function inferTaxYear(transactions) {
290
541
  const counts = {};
291
542
  for (const t of transactions) {
@@ -377,9 +628,9 @@ var Calculator = class {
377
628
  sellDate: tx.date,
378
629
  buyPriceEUR: lot.pricePerUnitEUR,
379
630
  sellPriceEUR: sellPricePerUnitEUR,
380
- buyCostEUR: roundHalfUp(buyCostEUR),
381
- sellProceedsEUR: roundHalfUp(sellProceedsEUR),
382
- gainLossEUR: roundHalfUp(gainLossEUR),
631
+ buyCostEUR: roundHalfUp2(buyCostEUR),
632
+ sellProceedsEUR: roundHalfUp2(sellProceedsEUR),
633
+ gainLossEUR: roundHalfUp2(gainLossEUR),
383
634
  buyFxRate: lot.fxRate,
384
635
  sellFxRate: tx.fxRate
385
636
  });
@@ -434,12 +685,12 @@ var Calculator = class {
434
685
  const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
435
686
  taxRate,
436
687
  assetClasses: [...g.assetClasses],
437
- plusvalenze: roundHalfUp(g.plusvalenze),
438
- imposta: roundHalfUp(g.plusvalenze * taxRate)
688
+ plusvalenze: roundHalfUp2(g.plusvalenze),
689
+ imposta: roundHalfUp2(g.plusvalenze * taxRate)
439
690
  }));
440
691
  const bucketAReport = {
441
692
  groups: bucketAGroups,
442
- totalImposta: roundHalfUp(
693
+ totalImposta: roundHalfUp2(
443
694
  bucketAGroups.reduce((s, g) => s + g.imposta, 0)
444
695
  )
445
696
  };
@@ -450,8 +701,8 @@ var Calculator = class {
450
701
  if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
451
702
  else bMinusvalenze += Math.abs(lot.gainLossEUR);
452
703
  }
453
- bPlusvalenze = roundHalfUp(bPlusvalenze);
454
- bMinusvalenze = roundHalfUp(bMinusvalenze);
704
+ bPlusvalenze = roundHalfUp2(bPlusvalenze);
705
+ bMinusvalenze = roundHalfUp2(bMinusvalenze);
455
706
  const carryForwards = [...this._options.carryForward ?? []].sort(
456
707
  (a, b) => a.year - b.year
457
708
  );
@@ -464,11 +715,11 @@ var Calculator = class {
464
715
  carryForwardApplied += consumed;
465
716
  remaining -= consumed;
466
717
  }
467
- carryForwardApplied = roundHalfUp(carryForwardApplied);
468
- const bNetResult = roundHalfUp(
718
+ carryForwardApplied = roundHalfUp2(carryForwardApplied);
719
+ const bNetResult = roundHalfUp2(
469
720
  bPlusvalenze - bMinusvalenze - carryForwardApplied
470
721
  );
471
- const carryForwardRemaining = roundHalfUp(Math.max(0, -bNetResult));
722
+ const carryForwardRemaining = roundHalfUp2(Math.max(0, -bNetResult));
472
723
  const bucketBReport = {
473
724
  plusvalenze: bPlusvalenze,
474
725
  minusvalenze: bMinusvalenze,
@@ -476,18 +727,41 @@ var Calculator = class {
476
727
  carryForwardRemaining,
477
728
  netResult: bNetResult
478
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
+ );
479
752
  return {
480
753
  method,
481
754
  taxYear,
482
- plusvalenze: roundHalfUp(plusvalenze),
483
- minusvalenze: roundHalfUp(minusvalenze),
484
- netResult: roundHalfUp(plusvalenze - minusvalenze),
755
+ plusvalenze: roundHalfUp2(plusvalenze),
756
+ minusvalenze: roundHalfUp2(minusvalenze),
757
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
485
758
  lots: matchedLots,
486
759
  ratesUsed,
487
760
  warnings,
488
761
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
489
762
  bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
490
- bucketB: bucketBReport
763
+ bucketB: bucketBReport,
764
+ dichiarazione: dichiarazioneReport
491
765
  };
492
766
  } else {
493
767
  const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
@@ -502,9 +776,9 @@ var Calculator = class {
502
776
  return {
503
777
  method,
504
778
  taxYear,
505
- plusvalenze: roundHalfUp(plusvalenze),
506
- minusvalenze: roundHalfUp(minusvalenze),
507
- netResult: roundHalfUp(plusvalenze - minusvalenze),
779
+ plusvalenze: roundHalfUp2(plusvalenze),
780
+ minusvalenze: roundHalfUp2(minusvalenze),
781
+ netResult: roundHalfUp2(plusvalenze - minusvalenze),
508
782
  lots: matchedLots,
509
783
  ratesUsed,
510
784
  warnings,
@@ -726,7 +1000,7 @@ function classifyByType(isin, securityType, product, warnings) {
726
1000
  };
727
1001
  }
728
1002
  warnings.push(
729
- `Unrecognized type: ${securityType}. Please classify manually.`
1003
+ `Unrecognized type for ${isin}: ${securityType}. Please classify manually.`
730
1004
  );
731
1005
  return {
732
1006
  product,
@@ -739,6 +1013,84 @@ function classifyByType(isin, securityType, product, warnings) {
739
1013
  source: "user"
740
1014
  };
741
1015
  }
1016
+ var ASSET_CLASS_DEFAULTS = {
1017
+ ETF: {
1018
+ assetClass: "ETF",
1019
+ bucketGain: "A",
1020
+ bucketLoss: "B",
1021
+ taxRate: 0.26,
1022
+ whiteListed: null
1023
+ },
1024
+ Stock: {
1025
+ assetClass: "Stock",
1026
+ bucketGain: "B",
1027
+ bucketLoss: "B",
1028
+ taxRate: 0,
1029
+ whiteListed: null
1030
+ },
1031
+ ETC: {
1032
+ assetClass: "ETC",
1033
+ bucketGain: "B",
1034
+ bucketLoss: "B",
1035
+ taxRate: 0,
1036
+ whiteListed: null
1037
+ },
1038
+ GovtBondWL: {
1039
+ assetClass: "GovtBondWL",
1040
+ bucketGain: "A",
1041
+ bucketLoss: "B",
1042
+ taxRate: 0.125,
1043
+ whiteListed: true
1044
+ },
1045
+ GovtBondOther: {
1046
+ assetClass: "GovtBondOther",
1047
+ bucketGain: "A",
1048
+ bucketLoss: "B",
1049
+ taxRate: 0.26,
1050
+ whiteListed: false
1051
+ },
1052
+ CorpBond: {
1053
+ assetClass: "CorpBond",
1054
+ bucketGain: "B",
1055
+ bucketLoss: "B",
1056
+ taxRate: 0,
1057
+ whiteListed: null
1058
+ },
1059
+ Derivative: {
1060
+ assetClass: "Derivative",
1061
+ bucketGain: "B",
1062
+ bucketLoss: "B",
1063
+ taxRate: 0,
1064
+ whiteListed: null
1065
+ },
1066
+ LeverageCert: {
1067
+ assetClass: "LeverageCert",
1068
+ bucketGain: "B",
1069
+ bucketLoss: "B",
1070
+ taxRate: 0,
1071
+ whiteListed: null
1072
+ },
1073
+ CapProtectedCert: {
1074
+ assetClass: "CapProtectedCert",
1075
+ bucketGain: "A",
1076
+ bucketLoss: "B",
1077
+ taxRate: 0.26,
1078
+ whiteListed: null
1079
+ }
1080
+ };
1081
+ function buildEntryFromAssetClass(assetClass, product) {
1082
+ const mapped = ASSET_CLASS_DEFAULTS[assetClass];
1083
+ return {
1084
+ product,
1085
+ assetClass: mapped.assetClass,
1086
+ bucketGain: mapped.bucketGain,
1087
+ bucketLoss: mapped.bucketLoss,
1088
+ taxRate: mapped.taxRate,
1089
+ whiteListed: mapped.whiteListed,
1090
+ confirmedByUser: true,
1091
+ source: "user"
1092
+ };
1093
+ }
742
1094
  var Classifier = class {
743
1095
  interactive;
744
1096
  constructor(options) {
@@ -760,7 +1112,7 @@ var Classifier = class {
760
1112
  }
761
1113
  return parsed.classifications;
762
1114
  }
763
- async classify(transactions, sidecarPath, _httpPost = httpsPost) {
1115
+ async classify(transactions, sidecarPath, options, _httpPost = httpsPost) {
764
1116
  const isinToProduct = /* @__PURE__ */ new Map();
765
1117
  for (const tx of transactions) {
766
1118
  if (tx.isin && !isinToProduct.has(tx.isin)) {
@@ -768,13 +1120,21 @@ var Classifier = class {
768
1120
  }
769
1121
  }
770
1122
  const confirmed = {};
771
- if (fs2.existsSync(sidecarPath)) {
772
- const existingMap = await this.load(sidecarPath);
773
- for (const [isin, entry] of Object.entries(existingMap)) {
774
- if (entry.confirmedByUser) {
775
- confirmed[isin] = entry;
1123
+ if (sidecarPath !== void 0) {
1124
+ if (fs2.existsSync(sidecarPath)) {
1125
+ const existingMap = await this.load(sidecarPath);
1126
+ for (const [isin, entry] of Object.entries(existingMap)) {
1127
+ if (entry.confirmedByUser) {
1128
+ confirmed[isin] = entry;
1129
+ }
776
1130
  }
777
1131
  }
1132
+ } else if (options?.existingClassification) {
1133
+ Object.assign(confirmed, options.existingClassification);
1134
+ }
1135
+ for (const [isin, assetClass] of Object.entries(options?.overrides ?? {})) {
1136
+ const product = isinToProduct.get(isin) ?? isin;
1137
+ confirmed[isin] = buildEntryFromAssetClass(assetClass, product);
778
1138
  }
779
1139
  const toProcess = [];
780
1140
  for (const isin of isinToProduct.keys()) {
@@ -784,76 +1144,100 @@ var Classifier = class {
784
1144
  }
785
1145
  const warnings = [];
786
1146
  const newEntries = {};
787
- const BATCH_SIZE = 10;
788
- for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
789
- if (i > 0) {
790
- await new Promise((resolve) => setTimeout(resolve, 6e3));
1147
+ if (options?.offline) {
1148
+ for (const isin of toProcess) {
1149
+ const product = isinToProduct.get(isin) ?? isin;
1150
+ warnings.push(
1151
+ `Unrecognized type for ${isin}: unknown. Please classify manually.`
1152
+ );
1153
+ newEntries[isin] = {
1154
+ product,
1155
+ assetClass: "Stock",
1156
+ bucketGain: "B",
1157
+ bucketLoss: "B",
1158
+ taxRate: 0,
1159
+ whiteListed: null,
1160
+ confirmedByUser: false,
1161
+ source: "user"
1162
+ };
791
1163
  }
792
- const batch = toProcess.slice(i, i + BATCH_SIZE);
793
- const requestBody = JSON.stringify(
794
- batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
795
- );
796
- let response = null;
797
- for (let attempt = 0; attempt < 2; attempt++) {
798
- try {
799
- const r = await _httpPost(
800
- "https://api.openfigi.com/v3/mapping",
801
- requestBody,
802
- 1e4
803
- );
804
- if (r.status < 500) {
805
- response = r;
806
- break;
1164
+ } else {
1165
+ const BATCH_SIZE = 10;
1166
+ const totalBatches = Math.ceil(toProcess.length / BATCH_SIZE);
1167
+ for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
1168
+ if (i > 0) {
1169
+ await new Promise((resolve) => setTimeout(resolve, 6e3));
1170
+ }
1171
+ const batch = toProcess.slice(i, i + BATCH_SIZE);
1172
+ const requestBody = JSON.stringify(
1173
+ batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
1174
+ );
1175
+ let response = null;
1176
+ for (let attempt = 0; attempt < 2; attempt++) {
1177
+ try {
1178
+ const r = await _httpPost(
1179
+ "https://api.openfigi.com/v3/mapping",
1180
+ requestBody,
1181
+ 1e4
1182
+ );
1183
+ if (r.status < 500) {
1184
+ response = r;
1185
+ break;
1186
+ }
1187
+ } catch {
1188
+ throw new ClassificationError("NETWORK_ERROR");
807
1189
  }
808
- } catch {
1190
+ }
1191
+ if (response === null) {
809
1192
  throw new ClassificationError("NETWORK_ERROR");
810
1193
  }
811
- }
812
- if (response === null) {
813
- throw new ClassificationError("NETWORK_ERROR");
814
- }
815
- const results = JSON.parse(response.data);
816
- for (let j = 0; j < batch.length; j++) {
817
- const isin = batch[j];
818
- const result = results[j];
819
- const product = isinToProduct.get(isin) ?? isin;
820
- if (!result || result.error || !result.data || result.data.length === 0) {
821
- warnings.push(
822
- `Unrecognized type: unknown. Please classify manually.`
823
- );
824
- newEntries[isin] = {
825
- product,
826
- assetClass: "Stock",
827
- bucketGain: "B",
828
- bucketLoss: "B",
829
- taxRate: 0,
830
- whiteListed: null,
831
- confirmedByUser: false,
832
- source: "user"
833
- };
834
- continue;
1194
+ const results = JSON.parse(response.data);
1195
+ for (let j = 0; j < batch.length; j++) {
1196
+ const isin = batch[j];
1197
+ const result = results[j];
1198
+ const product = isinToProduct.get(isin) ?? isin;
1199
+ if (!result || result.error || !result.data || result.data.length === 0) {
1200
+ warnings.push(
1201
+ `Unrecognized type for ${isin}: unknown. Please classify manually.`
1202
+ );
1203
+ newEntries[isin] = {
1204
+ product,
1205
+ assetClass: "Stock",
1206
+ bucketGain: "B",
1207
+ bucketLoss: "B",
1208
+ taxRate: 0,
1209
+ whiteListed: null,
1210
+ confirmedByUser: false,
1211
+ source: "user"
1212
+ };
1213
+ continue;
1214
+ }
1215
+ const st = result.data[0]?.securityType;
1216
+ const st2 = result.data[0]?.securityType2;
1217
+ const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
1218
+ newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
835
1219
  }
836
- const st = result.data[0]?.securityType;
837
- const st2 = result.data[0]?.securityType2;
838
- const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
839
- newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
1220
+ const batchIndex = i / BATCH_SIZE;
1221
+ options?.onBatchProgress?.(batchIndex + 1, totalBatches);
840
1222
  }
841
1223
  }
842
1224
  const mergedMap = { ...newEntries, ...confirmed };
843
- const sidecarContent = JSON.stringify(
844
- {
845
- version: 1,
846
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
847
- classifications: mergedMap,
848
- warnings
849
- },
850
- null,
851
- 2
852
- );
853
- try {
854
- fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
855
- } catch {
856
- throw new ClassificationError("WRITE_ERROR");
1225
+ if (sidecarPath !== void 0) {
1226
+ const sidecarContent = JSON.stringify(
1227
+ {
1228
+ version: 1,
1229
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1230
+ classifications: mergedMap,
1231
+ warnings
1232
+ },
1233
+ null,
1234
+ 2
1235
+ );
1236
+ try {
1237
+ fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
1238
+ } catch {
1239
+ throw new ClassificationError("WRITE_ERROR");
1240
+ }
857
1241
  }
858
1242
  return mergedMap;
859
1243
  }