@accounter/scraper-app 0.0.1

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.
Files changed (96) hide show
  1. package/README.md +90 -0
  2. package/docs/plan.md +76 -0
  3. package/index.html +12 -0
  4. package/package.json +40 -0
  5. package/src/env.template +2 -0
  6. package/src/server/__tests__/accounts-routes.test.ts +133 -0
  7. package/src/server/__tests__/check-accounts.test.ts +305 -0
  8. package/src/server/__tests__/filter-payload.test.ts +193 -0
  9. package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
  10. package/src/server/__tests__/graphql-client.test.ts +508 -0
  11. package/src/server/__tests__/healthz.test.ts +22 -0
  12. package/src/server/__tests__/history.test.ts +111 -0
  13. package/src/server/__tests__/otp-manager.test.ts +132 -0
  14. package/src/server/__tests__/scrape-runner.test.ts +144 -0
  15. package/src/server/__tests__/settings-routes.test.ts +117 -0
  16. package/src/server/__tests__/sources-routes.test.ts +149 -0
  17. package/src/server/__tests__/validate-payload.test.ts +193 -0
  18. package/src/server/__tests__/vault-routes.test.ts +174 -0
  19. package/src/server/__tests__/vault.test.ts +33 -0
  20. package/src/server/__tests__/websocket.test.ts +151 -0
  21. package/src/server/account-discovery.ts +49 -0
  22. package/src/server/accounts-routes.ts +74 -0
  23. package/src/server/check-accounts.ts +79 -0
  24. package/src/server/filter-payload.ts +145 -0
  25. package/src/server/graphql/client.ts +103 -0
  26. package/src/server/graphql/mutations.ts +518 -0
  27. package/src/server/history-routes.ts +11 -0
  28. package/src/server/history.ts +53 -0
  29. package/src/server/index.ts +40 -0
  30. package/src/server/otp-manager.ts +63 -0
  31. package/src/server/payload-schemas/amex.schema.ts +2 -0
  32. package/src/server/payload-schemas/cal.schema.ts +27 -0
  33. package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
  34. package/src/server/payload-schemas/discount.schema.ts +26 -0
  35. package/src/server/payload-schemas/isracard.schema.ts +58 -0
  36. package/src/server/payload-schemas/max.schema.ts +27 -0
  37. package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
  38. package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
  39. package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
  40. package/src/server/scrape-runner.ts +165 -0
  41. package/src/server/scrapers/__tests__/amex.test.ts +142 -0
  42. package/src/server/scrapers/__tests__/cal.test.ts +135 -0
  43. package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
  44. package/src/server/scrapers/__tests__/discount.test.ts +160 -0
  45. package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
  46. package/src/server/scrapers/__tests__/max.test.ts +115 -0
  47. package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
  48. package/src/server/scrapers/amex.ts +63 -0
  49. package/src/server/scrapers/cal.ts +56 -0
  50. package/src/server/scrapers/currency-rates.ts +64 -0
  51. package/src/server/scrapers/discount.ts +62 -0
  52. package/src/server/scrapers/isracard.ts +68 -0
  53. package/src/server/scrapers/max.ts +32 -0
  54. package/src/server/scrapers/poalim.ts +103 -0
  55. package/src/server/settings-routes.ts +27 -0
  56. package/src/server/sources-routes.ts +182 -0
  57. package/src/server/validate-payload.ts +74 -0
  58. package/src/server/vault-routes.ts +99 -0
  59. package/src/server/vault-store.ts +42 -0
  60. package/src/server/vault.ts +216 -0
  61. package/src/server/websocket.ts +454 -0
  62. package/src/shared/source-types.ts +10 -0
  63. package/src/shared/types.ts +20 -0
  64. package/src/shared/ws-protocol.ts +177 -0
  65. package/src/test-setup.ts +6 -0
  66. package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
  67. package/src/ui/__tests__/config.test.tsx +99 -0
  68. package/src/ui/__tests__/history.test.tsx +94 -0
  69. package/src/ui/__tests__/run.test.tsx +195 -0
  70. package/src/ui/__tests__/settings-tab.test.tsx +79 -0
  71. package/src/ui/__tests__/sources-tab.test.tsx +139 -0
  72. package/src/ui/__tests__/vault-setup.test.tsx +105 -0
  73. package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
  74. package/src/ui/app.tsx +109 -0
  75. package/src/ui/components/error-boundary.tsx +54 -0
  76. package/src/ui/components/otp-modal.tsx +82 -0
  77. package/src/ui/components/skeleton.tsx +58 -0
  78. package/src/ui/components/task-row.tsx +241 -0
  79. package/src/ui/contexts/vault-context.tsx +77 -0
  80. package/src/ui/lib/api.ts +117 -0
  81. package/src/ui/lib/ws.ts +137 -0
  82. package/src/ui/main.tsx +9 -0
  83. package/src/ui/screens/config/accounts-tab.tsx +185 -0
  84. package/src/ui/screens/config/config.tsx +163 -0
  85. package/src/ui/screens/config/settings-tab.tsx +167 -0
  86. package/src/ui/screens/config/source-forms.tsx +518 -0
  87. package/src/ui/screens/config/source-types.ts +91 -0
  88. package/src/ui/screens/config/sources-tab.tsx +176 -0
  89. package/src/ui/screens/history.tsx +234 -0
  90. package/src/ui/screens/run.tsx +266 -0
  91. package/src/ui/screens/vault-setup.tsx +120 -0
  92. package/src/ui/screens/vault-unlock.tsx +38 -0
  93. package/tsconfig.json +15 -0
  94. package/tsup.config.ts +10 -0
  95. package/vite.config.ts +24 -0
  96. package/vitest.config.ts +7 -0
@@ -0,0 +1,518 @@
1
+ import type { IsracardCardsTransactionsList } from '@accounter/modern-poalim-scraper';
2
+ import {
3
+ IsracardTransactionInput,
4
+ MutationUploadAmexTransactionsArgs,
5
+ MutationUploadIsracardTransactionsArgs,
6
+ } from '../gql/index.js';
7
+ import type { CalPayload } from '../payload-schemas/cal.schema.js';
8
+ import type { CurrencyRatesPayload } from '../payload-schemas/currency-rates.schema.js';
9
+ import type { DiscountPayload } from '../payload-schemas/discount.schema.js';
10
+ import type { MaxPayload } from '../payload-schemas/max.schema.js';
11
+ import type { PoalimForeignPayload } from '../payload-schemas/poalim-foreign.schema.js';
12
+ import type { PoalimIlsPayload } from '../payload-schemas/poalim-ils.schema.js';
13
+ import type { PoalimSwiftPayload } from '../payload-schemas/poalim-swift.schema.js';
14
+
15
+ // ── Mutation document strings ──────────────────────────────────────────────────
16
+
17
+ export const UPLOAD_POALIM_ILS = /* GraphQL */ `
18
+ mutation UploadPoalimIlsTransactions($transactions: [PoalimIlsTransactionInput!]!) {
19
+ uploadPoalimIlsTransactions(transactions: $transactions) {
20
+ inserted
21
+ skipped
22
+ insertedIds
23
+ insertedTransactions {
24
+ id
25
+ date
26
+ description
27
+ amount
28
+ account
29
+ }
30
+ changedTransactions {
31
+ id
32
+ changedFields {
33
+ field
34
+ oldValue
35
+ newValue
36
+ }
37
+ }
38
+ }
39
+ }
40
+ `;
41
+
42
+ export const UPLOAD_POALIM_FOREIGN = /* GraphQL */ `
43
+ mutation UploadPoalimForeignTransactions($transactions: [PoalimForeignTransactionInput!]!) {
44
+ uploadPoalimForeignTransactions(transactions: $transactions) {
45
+ inserted
46
+ skipped
47
+ insertedIds
48
+ insertedTransactions {
49
+ id
50
+ date
51
+ description
52
+ amount
53
+ account
54
+ }
55
+ changedTransactions {
56
+ id
57
+ changedFields {
58
+ field
59
+ oldValue
60
+ newValue
61
+ }
62
+ }
63
+ }
64
+ }
65
+ `;
66
+
67
+ export const UPLOAD_POALIM_SWIFT = /* GraphQL */ `
68
+ mutation UploadPoalimSwiftTransactions($swifts: [PoalimSwiftTransactionInput!]!) {
69
+ uploadPoalimSwiftTransactions(swifts: $swifts) {
70
+ inserted
71
+ skipped
72
+ insertedIds
73
+ insertedTransactions {
74
+ id
75
+ date
76
+ description
77
+ amount
78
+ account
79
+ }
80
+ changedTransactions {
81
+ id
82
+ changedFields {
83
+ field
84
+ oldValue
85
+ newValue
86
+ }
87
+ }
88
+ }
89
+ }
90
+ `;
91
+
92
+ export const UPLOAD_ISRACARD = /* GraphQL */ `
93
+ mutation UploadIsracardTransactions($transactions: [IsracardTransactionInput!]!) {
94
+ uploadIsracardTransactions(transactions: $transactions) {
95
+ inserted
96
+ skipped
97
+ insertedIds
98
+ insertedTransactions {
99
+ id
100
+ date
101
+ description
102
+ amount
103
+ account
104
+ }
105
+ changedTransactions {
106
+ id
107
+ changedFields {
108
+ field
109
+ oldValue
110
+ newValue
111
+ }
112
+ }
113
+ }
114
+ }
115
+ `;
116
+
117
+ export const UPLOAD_AMEX = /* GraphQL */ `
118
+ mutation UploadAmexTransactions($transactions: [AmexTransactionInput!]!) {
119
+ uploadAmexTransactions(transactions: $transactions) {
120
+ inserted
121
+ skipped
122
+ insertedIds
123
+ insertedTransactions {
124
+ id
125
+ date
126
+ description
127
+ amount
128
+ account
129
+ }
130
+ changedTransactions {
131
+ id
132
+ changedFields {
133
+ field
134
+ oldValue
135
+ newValue
136
+ }
137
+ }
138
+ }
139
+ }
140
+ `;
141
+
142
+ export const UPLOAD_CAL = /* GraphQL */ `
143
+ mutation UploadCalTransactions($transactions: [CalTransactionInput!]!) {
144
+ uploadCalTransactions(transactions: $transactions) {
145
+ inserted
146
+ skipped
147
+ insertedIds
148
+ insertedTransactions {
149
+ id
150
+ date
151
+ description
152
+ amount
153
+ account
154
+ }
155
+ changedTransactions {
156
+ id
157
+ changedFields {
158
+ field
159
+ oldValue
160
+ newValue
161
+ }
162
+ }
163
+ }
164
+ }
165
+ `;
166
+
167
+ export const UPLOAD_DISCOUNT = /* GraphQL */ `
168
+ mutation UploadDiscountTransactions($transactions: [DiscountTransactionInput!]!) {
169
+ uploadDiscountTransactions(transactions: $transactions) {
170
+ inserted
171
+ skipped
172
+ insertedIds
173
+ insertedTransactions {
174
+ id
175
+ date
176
+ description
177
+ amount
178
+ account
179
+ }
180
+ changedTransactions {
181
+ id
182
+ changedFields {
183
+ field
184
+ oldValue
185
+ newValue
186
+ }
187
+ }
188
+ }
189
+ }
190
+ `;
191
+
192
+ export const UPLOAD_MAX = /* GraphQL */ `
193
+ mutation UploadMaxTransactions($transactions: [MaxTransactionInput!]!) {
194
+ uploadMaxTransactions(transactions: $transactions) {
195
+ inserted
196
+ skipped
197
+ insertedIds
198
+ insertedTransactions {
199
+ id
200
+ date
201
+ description
202
+ amount
203
+ account
204
+ }
205
+ changedTransactions {
206
+ id
207
+ changedFields {
208
+ field
209
+ oldValue
210
+ newValue
211
+ }
212
+ }
213
+ }
214
+ }
215
+ `;
216
+
217
+ export const UPLOAD_CURRENCY_RATES = /* GraphQL */ `
218
+ mutation UploadCurrencyRates($rates: [CurrencyRateInput!]!) {
219
+ uploadCurrencyRates(rates: $rates) {
220
+ inserted
221
+ skipped
222
+ insertedIds
223
+ insertedTransactions {
224
+ id
225
+ date
226
+ description
227
+ amount
228
+ account
229
+ }
230
+ changedTransactions {
231
+ id
232
+ changedFields {
233
+ field
234
+ oldValue
235
+ newValue
236
+ }
237
+ }
238
+ }
239
+ }
240
+ `;
241
+
242
+ // ── Variable builders ─────────────────────────────────────────────────────────
243
+ // Each function maps a typed payload to the mutation variables object.
244
+ // Field names match the GraphQL input types in graphql schema, which mirror
245
+ // the pgtyped INSERT param shapes from the legacy scraper.
246
+
247
+ export function poalimIlsVars(payload: PoalimIlsPayload) {
248
+ // accountNumber / branchNumber / bankNumber are embedded in each transaction row
249
+ // via retrievalTransactionData — spread onto every transaction so the server can
250
+ // partition by account without a separate top-level arg.
251
+ const { accountNumber, branchNumber, bankNumber } = payload.retrievalTransactionData;
252
+ const transactions = payload.transactions.map(t => ({
253
+ ...t,
254
+ accountNumber,
255
+ branchNumber,
256
+ bankNumber,
257
+ // Coerce numeric values to String to match GraphQL scalar (server parses back)
258
+ referenceNumber: t.referenceNumber == null ? null : String(t.referenceNumber),
259
+ eventAmount: t.eventAmount == null ? null : String(t.eventAmount),
260
+ currentBalance: t.currentBalance == null ? null : String(t.currentBalance),
261
+ eventDate: t.eventDate == null ? null : String(t.eventDate),
262
+ expandedEventDate: t.expandedEventDate == null ? null : String(t.expandedEventDate),
263
+ eventId: t.eventId == null ? null : String(t.eventId),
264
+ }));
265
+ return { transactions };
266
+ }
267
+
268
+ export function poalimForeignVars(payload: PoalimForeignPayload) {
269
+ // The foreign payload groups transactions by currency under balancesAndLimitsDataList.
270
+ // Flatten to one row per transaction, carrying currencySwiftCode and account coords.
271
+ const transactions = payload.balancesAndLimitsDataList.flatMap(entry =>
272
+ entry.transactions.map(t => ({
273
+ ...t,
274
+ currencySwiftCode: entry.currencySwiftCode,
275
+ // Numeric fields coerced to String
276
+ eventAmount: t.eventAmount == null ? null : String(t.eventAmount),
277
+ currentBalance: t.currentBalance == null ? null : String(t.currentBalance),
278
+ currencyRate: t.currencyRate == null ? null : String(t.currencyRate),
279
+ referenceNumber: t.referenceNumber == null ? null : String(t.referenceNumber),
280
+ })),
281
+ );
282
+ return { transactions };
283
+ }
284
+
285
+ export function poalimSwiftVars(payload: PoalimSwiftPayload) {
286
+ const swifts = payload.swiftsList.map(s => ({
287
+ ...s,
288
+ startDate: s.startDate == null ? null : String(s.startDate),
289
+ amount: s.amount == null ? null : String(s.amount),
290
+ }));
291
+ return { swifts };
292
+ }
293
+
294
+ function transformIsracardAmexTransaction(
295
+ t:
296
+ | Exclude<
297
+ IsracardCardsTransactionsList['CardsTransactionsListBean']['Index0']['CurrentCardTransactions'][0]['txnIsrael'],
298
+ null | undefined
299
+ >[0]
300
+ | Exclude<
301
+ IsracardCardsTransactionsList['CardsTransactionsListBean']['Index0']['CurrentCardTransactions'][0]['txnAbroad'],
302
+ null | undefined
303
+ >[0],
304
+ card: string,
305
+ ): IsracardTransactionInput {
306
+ const inputTransaction: IsracardTransactionInput = {
307
+ card,
308
+ specificDate: t.specificDate,
309
+ cardIndex: Number.parseInt(t.cardIndex, 10),
310
+ dealsInbound: t.dealsInbound,
311
+ supplierId: t.supplierId ? Number(t.supplierId) : null,
312
+ supplierName: t.supplierName,
313
+ dealSumType: t.dealSumType,
314
+ paymentSumSign: t.paymentSumSign,
315
+ purchaseDate: t.purchaseDate,
316
+ fullPurchaseDate: t.fullPurchaseDate,
317
+ moreInfo: t.moreInfo,
318
+ horaatKeva: t.horaatKeva,
319
+ voucherNumber: t.voucherNumber ? Number(t.voucherNumber) : null,
320
+ voucherNumberRatz: t.voucherNumberRatz ? Number(t.voucherNumberRatz) : null,
321
+ solek: t.solek,
322
+ purchaseDateOutbound: t.purchaseDateOutbound,
323
+ fullPurchaseDateOutbound: t.fullPurchaseDateOutbound,
324
+ currencyId: t.currencyId,
325
+ currentPaymentCurrency: t.currentPaymentCurrency,
326
+ city: t.city,
327
+ supplierNameOutbound: t.supplierNameOutbound,
328
+ fullSupplierNameOutbound: t.fullSupplierNameOutbound,
329
+ paymentDate: t.paymentDate,
330
+ fullPaymentDate: t.fullPaymentDate,
331
+ isShowDealsOutbound: t.isShowDealsOutbound,
332
+ adendum: t.adendum,
333
+ voucherNumberRatzOutbound: t.voucherNumberRatzOutbound
334
+ ? Number(t.voucherNumberRatzOutbound)
335
+ : null,
336
+ isShowLinkForSupplierDetails: t.isShowLinkForSupplierDetails,
337
+ dealSum: t.dealSum,
338
+ paymentSum: t.paymentSum,
339
+ fullSupplierNameHeb: t.fullSupplierNameHeb,
340
+ dealSumOutbound: t.dealSumOutbound,
341
+ paymentSumOutbound: t.paymentSumOutbound,
342
+ isHoraatKeva: t.isHoraatKeva,
343
+ stage: t.stage,
344
+ returnCode: t.returnCode,
345
+ message: t.message,
346
+ returnMessage: t.returnMessage,
347
+ displayProperties: t.displayProperties,
348
+ tablePageNum: t.tablePageNum === '0' ? false : true,
349
+ isError: t.isError,
350
+ isCaptcha: t.isCaptcha,
351
+ isButton: t.isButton,
352
+ siteName: t.siteName,
353
+ kodMatbeaMekori: t.kodMatbeaMekori ?? null,
354
+ esbServicesCall: t.EsbServicesCall ?? null,
355
+ };
356
+
357
+ // remove known unstable keys from input transaction
358
+ const optionalTransactionKeys = [
359
+ 'clientIpAddress',
360
+ 'bcKey',
361
+ 'chargingDate',
362
+ 'requestNumber',
363
+ 'accountErrorCode',
364
+ 'monthlyRefundCardIndex',
365
+ 'id',
366
+ 'EsbServicesCall', // renamed to esbServicesCall above, to coerce to camelCase
367
+ ];
368
+
369
+ for (const key of optionalTransactionKeys) {
370
+ if (inputTransaction[key as keyof IsracardTransactionInput] !== undefined) {
371
+ delete inputTransaction[key as keyof IsracardTransactionInput];
372
+ }
373
+ }
374
+
375
+ return inputTransaction;
376
+ }
377
+
378
+ // Isracard / Amex: CardsTransactionsListBean → flatten to per-transaction rows.
379
+ // Each row gets `card` (the 4-digit card identifier from cardNumberList),
380
+ // matching the `card` column the legacy scraper writes.
381
+ function flattenIsracardAmexPayloads(
382
+ payloads: IsracardCardsTransactionsList[],
383
+ ): IsracardTransactionInput[] {
384
+ return payloads.flatMap(p => {
385
+ const cardNumbers = p.CardsTransactionsListBean.cardNumberList.map(c => c.match(/\d{4}/)?.[0]);
386
+ return Object.keys(p.CardsTransactionsListBean)
387
+ .filter(k => /^Index\d+$/.test(k))
388
+ .flatMap(k => {
389
+ const card = cardNumbers[Number(k.slice(5))]; // 'Index0' → 0 → cardNumbers[0]
390
+ if (!card) {
391
+ throw new Error(`Missing card number for ${k} in Isracard payload`);
392
+ }
393
+ const idx = p.CardsTransactionsListBean[
394
+ k
395
+ ] as IsracardCardsTransactionsList['CardsTransactionsListBean']['Index0'];
396
+ return idx.CurrentCardTransactions.flatMap(cardGroup => {
397
+ const israelTxns = (cardGroup.txnIsrael ?? []).map(t =>
398
+ transformIsracardAmexTransaction(t, card),
399
+ );
400
+ const abroadTxns = (cardGroup.txnAbroad ?? []).map(t =>
401
+ transformIsracardAmexTransaction(t, card),
402
+ );
403
+ return [...israelTxns, ...abroadTxns];
404
+ });
405
+ });
406
+ });
407
+ }
408
+
409
+ export function isracardVars(
410
+ payloads: IsracardCardsTransactionsList[],
411
+ ): MutationUploadIsracardTransactionsArgs {
412
+ return { transactions: flattenIsracardAmexPayloads(payloads) };
413
+ }
414
+
415
+ export function amexVars(
416
+ payloads: IsracardCardsTransactionsList[],
417
+ ): MutationUploadAmexTransactionsArgs {
418
+ return { transactions: flattenIsracardAmexPayloads(payloads) };
419
+ }
420
+
421
+ export function calVars(payload: CalPayload) {
422
+ // CalPayload is { card, month, transactions[] }[] — flatten to per-transaction rows.
423
+ // Each row gets `card` from the outer entry so the server knows which card it belongs to.
424
+ const transactions = payload.flatMap(entry =>
425
+ entry.transactions.map(t => ({
426
+ ...t,
427
+ card: entry.card,
428
+ // Coerce numeric string fields
429
+ trnAmt: t.trnAmt == null ? null : String(t.trnAmt),
430
+ amtBeforeConvAndIndex:
431
+ t.amtBeforeConvAndIndex == null ? null : String(t.amtBeforeConvAndIndex),
432
+ cashAccountTrnAmt: t.cashAccountTrnAmt == null ? null : String(t.cashAccountTrnAmt),
433
+ })),
434
+ );
435
+ return { transactions };
436
+ }
437
+
438
+ export function discountVars(payload: DiscountPayload) {
439
+ // DiscountPayload is { accountNumber, month, balance, transactions[] }[] — flatten.
440
+ // The scraper returns PascalCase field names (OperationDate, etc.); map to camelCase
441
+ // to match the GraphQL input type and DB column conventions.
442
+ const transactions = payload.flatMap(entry =>
443
+ entry.transactions.map(t => {
444
+ const raw = t as Record<string, unknown>;
445
+ return {
446
+ accountNumber: entry.accountNumber,
447
+ operationDate: raw['OperationDate'] ?? null,
448
+ valueDate: raw['ValueDate'] ?? null,
449
+ operationCode: raw['OperationCode'] ?? null,
450
+ operationDescription: raw['OperationDescription'] ?? null,
451
+ operationDescription2: raw['OperationDescription2'] ?? null,
452
+ operationDescription3: raw['OperationDescription3'] ?? null,
453
+ operationBranch: raw['OperationBranch'] ?? null,
454
+ operationBank: raw['OperationBank'] ?? null,
455
+ channel: raw['Channel'] ?? null,
456
+ channelName: raw['ChannelName'] ?? null,
457
+ checkNumber: raw['CheckNumber'] ?? null,
458
+ instituteCode: raw['InstituteCode'] ?? null,
459
+ operationAmount: raw['OperationAmount'] == null ? null : String(raw['OperationAmount']),
460
+ balanceAfterOperation:
461
+ raw['BalanceAfterOperation'] == null ? null : String(raw['BalanceAfterOperation']),
462
+ operationNumber: raw['OperationNumber'] ?? null,
463
+ branchTreasuryNumber: raw['BranchTreasuryNumber'] ?? null,
464
+ urn: raw['Urn'] ?? null,
465
+ operationDetailsServiceName: raw['OperationDetailsServiceName'] ?? null,
466
+ commissionChannelCode: raw['CommissionChannelCode'] ?? null,
467
+ commissionChannelName: raw['CommissionChannelName'] ?? null,
468
+ commissionTypeName: raw['CommissionTypeName'] ?? null,
469
+ businessDayDate: raw['BusinessDayDate'] ?? null,
470
+ eventName: raw['EventName'] ?? null,
471
+ categoryCode: raw['CategoryCode'] ?? null,
472
+ categoryDescCode: raw['CategoryDescCode'] ?? null,
473
+ categoryDescription: raw['CategoryDescription'] ?? null,
474
+ operationDescriptionToDisplay: raw['OperationDescriptionToDisplay'] ?? null,
475
+ operationOrder: raw['OperationOrder'] ?? null,
476
+ isLastSeen: raw['IsLastSeen'] ?? null,
477
+ };
478
+ }),
479
+ );
480
+ return { transactions };
481
+ }
482
+
483
+ export function maxVars(payload: MaxPayload) {
484
+ // MaxPayload is { accountNumber, txns[] }[] — flatten to per-transaction rows.
485
+ const transactions = payload.flatMap(entry =>
486
+ entry.txns.map(t => ({
487
+ ...t,
488
+ // Coerce numeric string fields
489
+ actualPaymentAmount: t.actualPaymentAmount == null ? null : String(t.actualPaymentAmount),
490
+ dealDataAmount: t.dealDataAmount == null ? null : String(t.dealDataAmount),
491
+ dealDataAmountIls: t.dealDataAmountIls == null ? null : String(t.dealDataAmountIls),
492
+ })),
493
+ );
494
+ return { transactions };
495
+ }
496
+
497
+ export function currencyRatesVars(payload: CurrencyRatesPayload) {
498
+ // CurrencyRatesPayload is { date, currency, rate }[] — one entry per currency per day.
499
+ // The DB table has one row per date with one column per currency.
500
+ // Pivot: group by date, set the matching currency column.
501
+ const byDate = new Map<string, Record<string, number | undefined>>();
502
+ for (const entry of payload) {
503
+ const row = byDate.get(entry.date) ?? {};
504
+ row[entry.currency.toLowerCase()] = entry.rate;
505
+ byDate.set(entry.date, row);
506
+ }
507
+ const rates = Array.from(byDate.entries()).map(([exchangeDate, cols]) => ({
508
+ exchangeDate,
509
+ usd: cols['usd'],
510
+ eur: cols['eur'],
511
+ gbp: cols['gbp'],
512
+ cad: cols['cad'],
513
+ jpy: cols['jpy'],
514
+ aud: cols['aud'],
515
+ sek: cols['sek'],
516
+ }));
517
+ return { rates };
518
+ }
@@ -0,0 +1,11 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { readHistory } from './history.js';
3
+ import { getVault, isLocked } from './vault-store.js';
4
+
5
+ export async function registerHistoryRoutes(app: FastifyInstance): Promise<void> {
6
+ app.get('/api/history', async (_req, reply) => {
7
+ if (isLocked()) return reply.status(401).send({ error: 'vault-locked' });
8
+ const { historyFilePath } = getVault().settings;
9
+ return readHistory(historyFilePath);
10
+ });
11
+ }
@@ -0,0 +1,53 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import type { RunRecord } from '../shared/types.js';
3
+
4
+ const MAX_RECORDS = 100;
5
+
6
+ // Per-file write queue: each appendRun chains onto the previous write for the
7
+ // same path, so concurrent calls never interleave their read-modify-write cycle.
8
+ const writeQueues = new Map<string, Promise<void>>();
9
+
10
+ function enqueue(filePath: string, work: () => Promise<void>): Promise<void> {
11
+ const prev = writeQueues.get(filePath) ?? Promise.resolve();
12
+ const next = prev.then(work, work); // run even if the previous write failed
13
+ writeQueues.set(filePath, next);
14
+ void next.finally(() => {
15
+ if (writeQueues.get(filePath) === next) writeQueues.delete(filePath);
16
+ });
17
+ return next;
18
+ }
19
+
20
+ async function doAppend(record: RunRecord, filePath: string): Promise<void> {
21
+ let records: RunRecord[] = [];
22
+ try {
23
+ const raw = await readFile(filePath, 'utf8');
24
+ const parsed = JSON.parse(raw);
25
+ records = Array.isArray(parsed) ? (parsed as RunRecord[]) : [];
26
+ } catch {
27
+ // file missing or unparseable — start fresh
28
+ }
29
+ records.push(record);
30
+ if (records.length > MAX_RECORDS) {
31
+ records = records.slice(-MAX_RECORDS);
32
+ }
33
+ await writeFile(filePath, JSON.stringify(records, null, 2), 'utf8');
34
+ }
35
+
36
+ export function appendRun(record: RunRecord, filePath: string): Promise<void> {
37
+ return enqueue(filePath, () => doAppend(record, filePath));
38
+ }
39
+
40
+ export async function readHistory(filePath: string): Promise<RunRecord[]> {
41
+ try {
42
+ const raw = await readFile(filePath, 'utf8');
43
+ const parsed = JSON.parse(raw);
44
+ const records = Array.isArray(parsed) ? (parsed as RunRecord[]) : [];
45
+ return records.slice(-MAX_RECORDS).reverse();
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+
51
+ export async function clearHistory(filePath: string): Promise<void> {
52
+ await writeFile(filePath, '[]', 'utf8');
53
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import Fastify, { type FastifyInstance } from 'fastify';
5
+ import staticPlugin from '@fastify/static';
6
+ import { registerHistoryRoutes } from './history-routes.js';
7
+ import { registerVaultRoutes } from './vault-routes.js';
8
+ import { registerWebSocketRoute } from './websocket.js';
9
+
10
+ export async function buildServer(): Promise<FastifyInstance> {
11
+ const app = Fastify({ logger: true });
12
+
13
+ app.get('/healthz', async () => ({ ok: true }));
14
+
15
+ await registerVaultRoutes(app);
16
+ await registerHistoryRoutes(app);
17
+ await registerWebSocketRoute(app);
18
+
19
+ // Serve compiled UI in production (dist/ui built by `yarn build:ui`)
20
+ // Server bundle is at dist/server/index.js; Vite outputs the SPA to dist/ui/
21
+ const uiRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../ui');
22
+ if (existsSync(uiRoot)) {
23
+ await app.register(staticPlugin, { root: uiRoot, prefix: '/', wildcard: true });
24
+ // Serve index.html for any path that doesn't match a static asset (SPA client-side routing)
25
+ app.setNotFoundHandler((_req, reply) => reply.sendFile('index.html'));
26
+ }
27
+
28
+ return app;
29
+ }
30
+
31
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
32
+ const port = process.env['PORT'] ? Number(process.env['PORT']) : 4001;
33
+ const app = await buildServer();
34
+ try {
35
+ await app.listen({ port, host: '0.0.0.0' });
36
+ } catch (err) {
37
+ app.log.error(err);
38
+ process.exit(1);
39
+ }
40
+ }
@@ -0,0 +1,63 @@
1
+ import type { ServerMessage } from '../shared/ws-protocol.js';
2
+
3
+ export class OtpTimeoutError extends Error {
4
+ constructor(public readonly sourceId: string) {
5
+ super(`OTP timeout for source "${sourceId}"`);
6
+ this.name = 'OtpTimeoutError';
7
+ }
8
+ }
9
+
10
+ export class OtpCancelledError extends Error {
11
+ constructor(public readonly sourceId: string) {
12
+ super(`OTP cancelled for source "${sourceId}"`);
13
+ this.name = 'OtpCancelledError';
14
+ }
15
+ }
16
+
17
+ type PendingEntry = {
18
+ resolve: (otp: string) => void;
19
+ reject: (err: Error) => void;
20
+ timer: ReturnType<typeof setTimeout>;
21
+ };
22
+
23
+ export class OtpManager {
24
+ private readonly pending = new Map<string, PendingEntry>();
25
+
26
+ waitForOtp(
27
+ sourceId: string,
28
+ emit: (msg: ServerMessage) => void,
29
+ timeoutMs = 120_000,
30
+ ): Promise<string> {
31
+ this.cancelOtp(sourceId);
32
+ return new Promise((resolve, reject) => {
33
+ emit({ type: 'otp-required', sourceId });
34
+
35
+ const timer = setTimeout(() => {
36
+ this.pending.delete(sourceId);
37
+ reject(new OtpTimeoutError(sourceId));
38
+ }, timeoutMs);
39
+
40
+ this.pending.set(sourceId, { resolve, reject, timer });
41
+ });
42
+ }
43
+
44
+ submitOtp(sourceId: string, otp: string): void {
45
+ const entry = this.pending.get(sourceId);
46
+ if (!entry) return;
47
+ clearTimeout(entry.timer);
48
+ this.pending.delete(sourceId);
49
+ entry.resolve(otp);
50
+ }
51
+
52
+ cancelOtp(sourceId: string): void {
53
+ const entry = this.pending.get(sourceId);
54
+ if (!entry) return;
55
+ clearTimeout(entry.timer);
56
+ this.pending.delete(sourceId);
57
+ entry.reject(new OtpCancelledError(sourceId));
58
+ }
59
+
60
+ hasPendingOtp(sourceId: string): boolean {
61
+ return this.pending.has(sourceId);
62
+ }
63
+ }
@@ -0,0 +1,2 @@
1
+ // Amex uses the same card-portal structure as Isracard
2
+ export { IsracardPayloadSchema as AmexPayloadSchema } from './isracard.schema.js';