UNPKG

@accounter/scraper-app

Version:
349 lines (323 loc) • 12.4 kB
import type { z } from 'zod'; import { HapoalimAccountData, HapoalimForeignTransactionsBusiness, HapoalimForeignTransactionsPersonal, HapoalimILSTransactions, init, SwiftTransaction, SwiftTransactions, type HapoalimOptions, } from '@accounter/modern-poalim-scraper'; import type { ServerMessage } from '../../shared/ws-protocol.js'; import { registerDiscoveredAccounts } from '../account-discovery.js'; import { checkAccounts } from '../check-accounts.js'; import { effectiveSet } from '../filter-payload.js'; import type { OtpManager } from '../otp-manager.js'; import type { PoalimSecuritiesInfoPayload } from '../payload-schemas/poalim-securities-info.schema.js'; import type { PoalimSecuritiesTransactionsPayload } from '../payload-schemas/poalim-securities-transactions.schema.js'; import { BlockedError } from '../scrape-runner.js'; import { validatePayload } from '../validate-payload.js'; import { getVault } from '../vault-store.js'; import type { PoalimAccountSchema } from '../vault.js'; export type PoalimCreds = z.infer<typeof PoalimAccountSchema>; export type DecoratedSwiftTransactions = Omit<SwiftTransactions, 'swiftsList'> & { swiftsList: (SwiftTransactions['swiftsList'][number] & { details: SwiftTransaction })[]; }; const OTP_TIMEOUT_MS = 5 * 60 * 1000; /** * The modern-poalim-scraper runs Zod schema validation internally (validateSchema: true) and * returns `{ data, isValid, errors }`. On a validation failure `data` is `null` and `isValid` is * `false`. If we only read `data` we silently treat a failed validation as "no data", which makes a * broken run look successful. This turns the validation error into a readable message so the caller * can surface it to the user. */ function describeValidationError(errors: unknown): string { if (errors == null) return 'unknown validation error'; if (typeof errors === 'string') return errors; if (errors instanceof Error) { // ZodError carries the individual issues on `.issues`; prefer those over the noisy message. if ('issues' in errors && Array.isArray(errors.issues)) { return describeValidationError(errors.issues); } return errors.message; } if (Array.isArray(errors)) { return errors .map(err => { if (err && typeof err === 'object' && 'message' in err) { const pathStr = 'path' in err && Array.isArray(err.path) && err.path.length > 0 ? ` at ${err.path.join('.')}` : ''; return `${(err as { message: unknown }).message}${pathStr}`; } return String(err); }) .join(', '); } try { return JSON.stringify(errors); } catch { return String(errors); } } export async function scrapePoalim( creds: PoalimCreds, dateFrom: Date, dateTo: Date, headless: boolean, otpManager: OtpManager, emit: (msg: ServerMessage) => void, ): Promise< { ils: HapoalimILSTransactions | null; foreign: HapoalimForeignTransactionsPersonal | HapoalimForeignTransactionsBusiness | null; swift: DecoratedSwiftTransactions | null; securitiesInfo: PoalimSecuritiesInfoPayload | null; securitiesTransactions: PoalimSecuritiesTransactionsPayload | null; bankAccount: { bankNumber: number; branchNumber: number; accountNumber: number }; }[] > { const otpCallback = () => otpManager.waitForOtp(creds.id, emit, OTP_TIMEOUT_MS); const { hapoalim: hapoalimFn, close } = await init({ headless }); // calculate duration based on dateFrom and today. return number of months between the two dates, with a minimum of 1 month const months = Math.max( 1, (dateTo.getFullYear() - dateFrom.getFullYear()) * 12 + (dateTo.getMonth() - dateFrom.getMonth()) + 1, ); const options: HapoalimOptions = { isBusiness: creds.options?.isBusinessAccount ?? false, otpCallback, duration: months, validateSchema: true, }; try { const scraper = await hapoalimFn( { userCode: creds.userCode, password: creds.password }, options, ); if (scraper === 'Unknown Error') { throw new Error('Hapoalim login failed: Unknown Error'); } const { data: accounts, isValid: accountsValid, errors: accountsErrors, } = await scraper.getAccountsData(); if (accountsValid === false) { throw new Error( `Poalim accounts data failed schema validation: ${describeValidationError(accountsErrors)}`, ); } if (!accounts || accounts.length === 0) { return []; } emit({ type: 'task-accounts-found', sourceId: creds.id, accounts: accounts.map(a => ({ accountNumber: String(a.accountNumber), branchNumber: a.branchNumber, bankNumber: a.bankNumber, })), }); const opts = creds.options; const filteredAccounts: HapoalimAccountData = []; for (const account of accounts) { const { accountNumber, branchNumber } = account; const allAccounts = [String(accountNumber)]; const allBranches = [String(branchNumber)]; const allowedAccounts = effectiveSet( opts?.acceptedAccountNumbers, opts?.ignoredAccountNumbers, allAccounts, ); const allowedBranches = effectiveSet( opts?.acceptedBranchNumbers, opts?.ignoredBranchNumbers, allBranches, ); if ( !allowedAccounts.has(String(accountNumber)) || !allowedBranches.has(String(branchNumber)) ) { continue; } filteredAccounts.push(account); } // Register any newly discovered accounts as pending, then re-read to avoid stale closure await registerDiscoveredAccounts('poalim', creds.id, filteredAccounts); const currentAccountRecords = getVault().accountRecords; // Check for unknown accounts across all ILS payloads (ILS carries account identifiers) const allUnknown: string[] = []; for (const account of filteredAccounts) { const check = checkAccounts('poalim', account, currentAccountRecords); allUnknown.push(...check.unknown); // Emit vault-checked status based on whether account was excluded emit({ type: 'task-account-vault-checked', sourceId: creds.id, accountId: `${account.branchNumber}-${account.accountNumber}`, status: 'accepted', }); } if (allUnknown.length > 0) { emit({ type: 'task-blocked', sourceId: creds.id, sourceType: 'poalim', unknownAccounts: [...new Set(allUnknown)], }); throw new BlockedError([...new Set(allUnknown)]); } const accountsData: { ils: HapoalimILSTransactions | null; foreign: HapoalimForeignTransactionsPersonal | HapoalimForeignTransactionsBusiness | null; swift: DecoratedSwiftTransactions | null; securitiesInfo: PoalimSecuritiesInfoPayload | null; securitiesTransactions: PoalimSecuritiesTransactionsPayload | null; bankAccount: { bankNumber: number; branchNumber: number; accountNumber: number }; }[] = []; const isBusiness = creds.options?.isBusinessAccount ?? false; const fetchSecurities = creds.options?.fetchSecurities ?? false; for (const account of filteredAccounts) { let ils: HapoalimILSTransactions | null = null; let foreign: HapoalimForeignTransactionsPersonal | HapoalimForeignTransactionsBusiness | null = null; let swift: DecoratedSwiftTransactions | null = null; let securitiesInfo: PoalimSecuritiesInfoPayload | null = null; let securitiesTransactions: PoalimSecuritiesTransactionsPayload | null = null; const accountId = `${account.branchNumber}-${account.accountNumber}`; const accountRef = { bankNumber: account.bankNumber, branchNumber: account.branchNumber, accountNumber: account.accountNumber, }; emit({ type: 'task-account-txns-fetching', sourceId: creds.id, accountId, txnType: 'ils' }); const { data: ilsData, isValid: ilsValid, errors: ilsErrors, } = await scraper.getILSTransactions(accountRef); if (ilsValid === false) { throw new Error( `Poalim ILS transactions failed schema validation for account ${accountId}: ${describeValidationError(ilsErrors)}`, ); } if (ilsData) { ils = validatePayload('poalim-ils', ilsData); } emit({ type: 'task-account-txns-fetching', sourceId: creds.id, accountId, txnType: 'foreign', }); const { data: foreignData, isValid: foreignValid, errors: foreignErrors, } = await scraper.getForeignTransactions(accountRef, isBusiness); if (foreignValid === false) { throw new Error( `Poalim foreign transactions failed schema validation for account ${accountId}: ${describeValidationError(foreignErrors)}`, ); } if (foreignData) { foreign = validatePayload('poalim-foreign', foreignData); } emit({ type: 'task-account-txns-fetching', sourceId: creds.id, accountId, txnType: 'swift' }); const { data: swiftData, isValid: swiftValid, errors: swiftErrors, } = await scraper.getForeignSwiftTransactions(accountRef); if (swiftValid === false) { throw new Error( `Poalim SWIFT transactions failed schema validation for account ${accountId}: ${describeValidationError(swiftErrors)}`, ); } if (swiftData) { const validated = validatePayload('poalim-swift', swiftData); const decoratedSwiftsList: DecoratedSwiftTransactions['swiftsList'] = []; for (const transaction of validated.swiftsList) { const extendedTransaction = await scraper.getForeignSwiftTransaction( accountRef, transaction.transferCatenatedId, transaction.dataOriginCode, ); if (!extendedTransaction.isValid || !extendedTransaction.data) { throw new Error( `Failed to fetch details for SWIFT transaction ${transaction.transferCatenatedId}`, ); } decoratedSwiftsList.push({ ...transaction, details: extendedTransaction.data }); } swift = { ...validated, swiftsList: decoratedSwiftsList }; } if (fetchSecurities) { emit({ type: 'task-account-txns-fetching', sourceId: creds.id, accountId, txnType: 'securitiesInfo', }); const { data: securitiesInfoData, isValid: securitiesInfoValid, errors: securitiesInfoErrors, } = await scraper.getSecuritiesInfo(accountRef); if (securitiesInfoValid === false) { throw new Error( `Poalim securities info failed schema validation for account ${accountId}: ${describeValidationError(securitiesInfoErrors)}`, ); } if (securitiesInfoData) { securitiesInfo = validatePayload('poalim-securities-info', securitiesInfoData); } emit({ type: 'task-account-txns-fetching', sourceId: creds.id, accountId, txnType: 'securitiesTransactions', }); const { data: securitiesTransactionsData, isValid: securitiesTransactionsValid, errors: securitiesTransactionsErrors, } = await scraper.getSecuritiesTransactions(accountRef, { fromDate: dateFrom, toDate: dateTo, }); if (securitiesTransactionsValid === false) { throw new Error( `Poalim securities transactions failed schema validation for account ${accountId}: ${describeValidationError(securitiesTransactionsErrors)}`, ); } if (securitiesTransactionsData) { securitiesTransactions = validatePayload( 'poalim-securities-transactions', securitiesTransactionsData, ); } } accountsData.push({ ils, foreign, swift, securitiesInfo, securitiesTransactions, bankAccount: accountRef, }); } return accountsData; } catch (error) { console.error('Error in scrapePoalim:', error instanceof Error ? error.stack : error); throw error; } finally { await close(); } }