@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.
- package/README.md +90 -0
- package/docs/plan.md +76 -0
- package/index.html +12 -0
- package/package.json +40 -0
- package/src/env.template +2 -0
- package/src/server/__tests__/accounts-routes.test.ts +133 -0
- package/src/server/__tests__/check-accounts.test.ts +305 -0
- package/src/server/__tests__/filter-payload.test.ts +193 -0
- package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
- package/src/server/__tests__/graphql-client.test.ts +508 -0
- package/src/server/__tests__/healthz.test.ts +22 -0
- package/src/server/__tests__/history.test.ts +111 -0
- package/src/server/__tests__/otp-manager.test.ts +132 -0
- package/src/server/__tests__/scrape-runner.test.ts +144 -0
- package/src/server/__tests__/settings-routes.test.ts +117 -0
- package/src/server/__tests__/sources-routes.test.ts +149 -0
- package/src/server/__tests__/validate-payload.test.ts +193 -0
- package/src/server/__tests__/vault-routes.test.ts +174 -0
- package/src/server/__tests__/vault.test.ts +33 -0
- package/src/server/__tests__/websocket.test.ts +151 -0
- package/src/server/account-discovery.ts +49 -0
- package/src/server/accounts-routes.ts +74 -0
- package/src/server/check-accounts.ts +79 -0
- package/src/server/filter-payload.ts +145 -0
- package/src/server/graphql/client.ts +103 -0
- package/src/server/graphql/mutations.ts +518 -0
- package/src/server/history-routes.ts +11 -0
- package/src/server/history.ts +53 -0
- package/src/server/index.ts +40 -0
- package/src/server/otp-manager.ts +63 -0
- package/src/server/payload-schemas/amex.schema.ts +2 -0
- package/src/server/payload-schemas/cal.schema.ts +27 -0
- package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
- package/src/server/payload-schemas/discount.schema.ts +26 -0
- package/src/server/payload-schemas/isracard.schema.ts +58 -0
- package/src/server/payload-schemas/max.schema.ts +27 -0
- package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
- package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
- package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
- package/src/server/scrape-runner.ts +165 -0
- package/src/server/scrapers/__tests__/amex.test.ts +142 -0
- package/src/server/scrapers/__tests__/cal.test.ts +135 -0
- package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
- package/src/server/scrapers/__tests__/discount.test.ts +160 -0
- package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
- package/src/server/scrapers/__tests__/max.test.ts +115 -0
- package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
- package/src/server/scrapers/amex.ts +63 -0
- package/src/server/scrapers/cal.ts +56 -0
- package/src/server/scrapers/currency-rates.ts +64 -0
- package/src/server/scrapers/discount.ts +62 -0
- package/src/server/scrapers/isracard.ts +68 -0
- package/src/server/scrapers/max.ts +32 -0
- package/src/server/scrapers/poalim.ts +103 -0
- package/src/server/settings-routes.ts +27 -0
- package/src/server/sources-routes.ts +182 -0
- package/src/server/validate-payload.ts +74 -0
- package/src/server/vault-routes.ts +99 -0
- package/src/server/vault-store.ts +42 -0
- package/src/server/vault.ts +216 -0
- package/src/server/websocket.ts +454 -0
- package/src/shared/source-types.ts +10 -0
- package/src/shared/types.ts +20 -0
- package/src/shared/ws-protocol.ts +177 -0
- package/src/test-setup.ts +6 -0
- package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
- package/src/ui/__tests__/config.test.tsx +99 -0
- package/src/ui/__tests__/history.test.tsx +94 -0
- package/src/ui/__tests__/run.test.tsx +195 -0
- package/src/ui/__tests__/settings-tab.test.tsx +79 -0
- package/src/ui/__tests__/sources-tab.test.tsx +139 -0
- package/src/ui/__tests__/vault-setup.test.tsx +105 -0
- package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
- package/src/ui/app.tsx +109 -0
- package/src/ui/components/error-boundary.tsx +54 -0
- package/src/ui/components/otp-modal.tsx +82 -0
- package/src/ui/components/skeleton.tsx +58 -0
- package/src/ui/components/task-row.tsx +241 -0
- package/src/ui/contexts/vault-context.tsx +77 -0
- package/src/ui/lib/api.ts +117 -0
- package/src/ui/lib/ws.ts +137 -0
- package/src/ui/main.tsx +9 -0
- package/src/ui/screens/config/accounts-tab.tsx +185 -0
- package/src/ui/screens/config/config.tsx +163 -0
- package/src/ui/screens/config/settings-tab.tsx +167 -0
- package/src/ui/screens/config/source-forms.tsx +518 -0
- package/src/ui/screens/config/source-types.ts +91 -0
- package/src/ui/screens/config/sources-tab.tsx +176 -0
- package/src/ui/screens/history.tsx +234 -0
- package/src/ui/screens/run.tsx +266 -0
- package/src/ui/screens/vault-setup.tsx +120 -0
- package/src/ui/screens/vault-unlock.tsx +38 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +24 -0
- 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
|
+
}
|