UNPKG

@accounter/scraper-app

Version:
1,619 lines (1,598 loc) • 125 kB
// src/server/index.ts import { existsSync } from "fs"; import path2 from "path"; import { fileURLToPath, pathToFileURL } from "url"; import Fastify from "fastify"; import staticPlugin from "@fastify/static"; // src/server/history.ts import { readFile, writeFile } from "fs/promises"; var MAX_RECORDS = 100; var writeQueues = /* @__PURE__ */ new Map(); function enqueue(filePath, work) { const prev = writeQueues.get(filePath) ?? Promise.resolve(); const next = prev.then(work, work); writeQueues.set(filePath, next); void next.finally(() => { if (writeQueues.get(filePath) === next) writeQueues.delete(filePath); }); return next; } async function doAppend(record, filePath) { let records = []; try { const raw = await readFile(filePath, "utf8"); const parsed = JSON.parse(raw); records = Array.isArray(parsed) ? parsed : []; } catch { } records.push(record); if (records.length > MAX_RECORDS) { records = records.slice(-MAX_RECORDS); } await writeFile(filePath, JSON.stringify(records, null, 2), "utf8"); } function appendRun(record, filePath) { return enqueue(filePath, () => doAppend(record, filePath)); } async function readHistory(filePath) { try { const raw = await readFile(filePath, "utf8"); const parsed = JSON.parse(raw); const records = Array.isArray(parsed) ? parsed : []; return records.slice(-MAX_RECORDS).reverse(); } catch { return []; } } // src/server/vault-store.ts import { constants } from "fs"; import { access, copyFile, mkdir, rename, unlink } from "fs/promises"; import path from "path"; // src/server/vault.ts import { createCipheriv, createDecipheriv, randomBytes, scrypt } from "crypto"; import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises"; import { z, ZodError } from "zod"; function getVaultPath() { return process.env["VAULT_PATH"] ?? ".vault"; } var ALGORITHM = "aes-256-gcm"; var KEY_LEN = 32; var SALT_LEN = 32; var IV_LEN = 12; var TAG_LEN = 16; var SCRYPT_N = 65536; var SCRYPT_R = 8; var SCRYPT_P = 1; var SCRYPT_MAXMEM = 128 * SCRYPT_N * SCRYPT_R * 2; var HEADER_LEN = 6; var PoalimAccountSchema = z.object({ id: z.string(), nickname: z.string().optional(), userCode: z.string(), password: z.string(), options: z.object({ isBusinessAccount: z.boolean().optional(), acceptedAccountNumbers: z.array(z.string()).optional(), acceptedBranchNumbers: z.array(z.string()).optional(), ignoredAccountNumbers: z.array(z.string()).optional(), ignoredBranchNumbers: z.array(z.string()).optional() }).optional() }); var DiscountAccountSchema = z.object({ id: z.string(), ID: z.string(), password: z.string(), code: z.string().optional(), nickname: z.string().optional() }); var IsracardAmexAccountSchema = z.object({ id: z.string(), nickname: z.string().optional(), ownerId: z.string(), password: z.string(), last6Digits: z.string(), options: z.object({ acceptedCardNumbers: z.array(z.string()).optional(), ignoredCardNumbers: z.array(z.string()).optional(), cardNumberMapping: z.record(z.string(), z.string()).optional() }).optional() }); var CalAccountSchema = z.object({ id: z.string(), nickname: z.string().optional(), username: z.string(), password: z.string(), last4Digits: z.string(), options: z.object({ acceptedCardNumbers: z.array(z.string()).optional(), ignoredCardNumbers: z.array(z.string()).optional() }).optional() }); var MaxAccountSchema = z.object({ id: z.string(), nickname: z.string().optional(), username: z.string(), password: z.string(), options: z.object({ acceptedCardNumbers: z.array(z.string()).optional(), ignoredCardNumbers: z.array(z.string()).optional() }).optional() }); var OtsarHahayalAccountSchema = z.object({ id: z.string(), nickname: z.string().optional(), userCode: z.string(), password: z.string() }); var SettingsSchema = z.object({ showBrowser: z.boolean().default(false), fetchBankOfIsraelRates: z.boolean().default(true), concurrentScraping: z.boolean().default(false), defaultDateRangeMonths: z.number().int().positive().default(3), historyFilePath: z.string().default("./history.json"), saveHistory: z.boolean().default(true), serverUrl: z.string().optional(), apiKey: z.string().optional(), vaultPath: z.string().trim().min(1).optional() }); var AccountRecordSchema = z.object({ id: z.string(), sourceId: z.string(), sourceType: z.enum([ "poalim", "discount", "isracard", "amex", "cal", "max", "otsar-hahayal", "otsar-hahayal-credit-card" ]), accountNumber: z.string(), branchNumber: z.string().optional(), status: z.enum(["accepted", "ignored", "pending"]).default("pending") }); var VaultSchema = z.object({ poalimAccounts: z.array(PoalimAccountSchema).default([]), discountAccounts: z.array(DiscountAccountSchema).default([]), isracardAccounts: z.array(IsracardAmexAccountSchema).default([]), amexAccounts: z.array(IsracardAmexAccountSchema).default([]), calAccounts: z.array(CalAccountSchema).default([]), maxAccounts: z.array(MaxAccountSchema).default([]), otsarHahayalAccounts: z.array(OtsarHahayalAccountSchema).default([]), accountRecords: z.array(AccountRecordSchema).default([]), settings: SettingsSchema.default({ showBrowser: false, fetchBankOfIsraelRates: true, concurrentScraping: false, defaultDateRangeMonths: 3, historyFilePath: "./history.json", saveHistory: true }) }); function deriveKey(password, salt, options) { return new Promise((resolve, reject) => { scrypt(password, salt, KEY_LEN, options, (err, key) => { if (err) reject(err); else resolve(key); }); }); } async function encryptVault(vault, password) { const salt = randomBytes(SALT_LEN); const iv = randomBytes(IV_LEN); const key = await deriveKey(password, salt, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM }); const cipher = createCipheriv(ALGORITHM, key, iv); const encrypted = Buffer.concat([cipher.update(JSON.stringify(vault), "utf8"), cipher.final()]); const authTag = cipher.getAuthTag(); const header = Buffer.alloc(HEADER_LEN); header.writeUInt32BE(SCRYPT_N, 0); header.writeUInt8(SCRYPT_R, 4); header.writeUInt8(SCRYPT_P, 5); return Buffer.concat([header, salt, iv, authTag, encrypted]).toString("base64"); } async function decryptVault(blob, password) { try { const buf = Buffer.from(blob, "base64"); const N = buf.readUInt32BE(0); const r = buf.readUInt8(4); const p = buf.readUInt8(5); const salt = buf.subarray(HEADER_LEN, HEADER_LEN + SALT_LEN); const iv = buf.subarray(HEADER_LEN + SALT_LEN, HEADER_LEN + SALT_LEN + IV_LEN); const authTag = buf.subarray( HEADER_LEN + SALT_LEN + IV_LEN, HEADER_LEN + SALT_LEN + IV_LEN + TAG_LEN ); const body = buf.subarray(HEADER_LEN + SALT_LEN + IV_LEN + TAG_LEN); const key = await deriveKey(password, salt, { N, r, p, maxmem: 128 * N * r * 2 }); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); const plaintext = Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8"); return VaultSchema.parse(JSON.parse(plaintext)); } catch (err) { if (err instanceof ZodError) { console.error("[vault] schema validation failed after decryption:", err.issues); } return null; } } function defaultVault() { return VaultSchema.parse({ settings: {} }); } async function loadVaultFile(filePath, password) { const blob = await readFile2(filePath, "utf8"); return decryptVault(blob.trim(), password); } async function saveVaultFile(filePath, vault, password) { const data = await encryptVault(vault, password); await writeFile2(filePath, data, "utf8"); } // src/server/vault-store.ts var _vault = null; var _password = null; var _vaultPath = null; function getCurrentVaultPath() { return _vaultPath ?? getVaultPath(); } async function hasVaultFile() { return access(getCurrentVaultPath()).then(() => true).catch(() => false); } async function unlockVault(password) { if (!await hasVaultFile()) return "not-found"; const vault = await loadVaultFile(getVaultPath(), password); if (vault === null) return "wrong-password"; _vault = vault; _password = password; _vaultPath = getVaultPath(); return "ok"; } function getVault() { if (_vault === null) throw new Error("Vault is locked"); return _vault; } async function updateVault(fn) { if (_vault === null || _password === null) throw new Error("Vault is locked"); _vault = fn(_vault); await saveVaultFile(getCurrentVaultPath(), _vault, _password); } function isLocked() { return _vault === null; } function lockVault() { _vault = null; _password = null; _vaultPath = null; } async function moveVaultFile(newPath) { if (_vault === null || _password === null) throw new Error("Vault is locked"); const oldPath = path.resolve(getCurrentVaultPath()); const normalizedNewPath = path.resolve(newPath); if (oldPath === normalizedNewPath) return; await mkdir(path.dirname(normalizedNewPath), { recursive: true }); const destExists = await access(normalizedNewPath).then(() => true).catch(() => false); if (destExists) throw new Error("Destination already exists"); try { await rename(oldPath, normalizedNewPath); } catch (err) { if (err.code !== "EXDEV") throw err; await copyFile(oldPath, normalizedNewPath, constants.COPYFILE_EXCL); await unlink(oldPath); } _vaultPath = normalizedNewPath; process.env["VAULT_PATH"] = normalizedNewPath; } // src/server/history-routes.ts async function registerHistoryRoutes(app) { app.get("/api/history", async (_req, reply) => { if (isLocked()) return reply.status(401).send({ error: "vault-locked" }); const { historyFilePath } = getVault().settings; return readHistory(historyFilePath); }); } // src/server/vault-routes.ts import { readFile as readFile3, rename as rename2, writeFile as writeFile3 } from "fs/promises"; // src/server/accounts-routes.ts function guardLocked(reply) { if (isLocked()) return reply.status(401).send({ error: "vault-locked" }); return null; } var SOURCE_TYPE_TO_VAULT_KEY = { poalim: "poalimAccounts", discount: "discountAccounts", isracard: "isracardAccounts", amex: "amexAccounts", cal: "calAccounts", max: "maxAccounts", "otsar-hahayal": "otsarHahayalAccounts", "otsar-hahayal-credit-card": "otsarHahayalAccounts" }; async function registerAccountsRoutes(app) { app.get("/api/vault/accounts", async (_req, reply) => { const blocked = guardLocked(reply); if (blocked) return blocked; const vault = getVault(); const { accountRecords } = vault; return accountRecords.map((r) => { const vaultKey = SOURCE_TYPE_TO_VAULT_KEY[r.sourceType]; const accounts = vaultKey ? vault[vaultKey] : void 0; return { ...r, nickname: accounts?.find((a) => a.id === r.sourceId)?.nickname }; }); }); app.put( "/api/vault/accounts/:id", async (req, reply) => { const blocked = guardLocked(reply); if (blocked) return blocked; const { id } = req.params; const { status } = req.body; if (status !== "accepted" && status !== "ignored") { return reply.status(400).send({ error: "status must be accepted or ignored" }); } const vault = getVault(); if (!vault.accountRecords.some((a) => a.id === id)) { return reply.status(404).send({ error: "not-found" }); } await updateVault((v) => { const idx = v.accountRecords.findIndex((a) => a.id === id); if (idx === -1) return v; const accounts = [...v.accountRecords]; accounts[idx] = { ...accounts[idx], status }; return { ...v, accountRecords: accounts }; }); return getVault().accountRecords; } ); app.delete("/api/vault/accounts/:id", async (req, reply) => { const blocked = guardLocked(reply); if (blocked) return blocked; const { id } = req.params; if (!getVault().accountRecords.some((a) => a.id === id)) { return reply.status(404).send({ error: "not-found" }); } await updateVault((v) => ({ ...v, accountRecords: v.accountRecords.filter((a) => a.id !== id) })); return getVault().accountRecords; }); } // src/server/settings-routes.ts function guardLocked2(reply) { if (isLocked()) return reply.status(401).send({ error: "vault-locked" }); return null; } async function registerSettingsRoutes(app) { app.get("/api/vault/settings", async (_req, reply) => { const blocked = guardLocked2(reply); if (blocked) return blocked; return getVault().settings; }); app.put("/api/vault/settings", async (req, reply) => { const blocked = guardLocked2(reply); if (blocked) return blocked; const parsed = SettingsSchema.partial().safeParse(req.body); if (!parsed.success) return reply.status(400).send({ error: parsed.error.issues }); const newPath = parsed.data.vaultPath; if (newPath !== void 0 && newPath !== getCurrentVaultPath()) { try { await moveVaultFile(newPath); } catch { return reply.status(500).send({ error: "move-failed" }); } } await updateVault((v) => ({ ...v, settings: { ...v.settings, ...parsed.data } })); return getVault().settings; }); } // src/server/sources-routes.ts import { randomUUID } from "crypto"; import { z as z2 } from "zod"; var SourceConfigSchema = z2.discriminatedUnion("type", [ PoalimAccountSchema.extend({ type: z2.literal("poalim") }), DiscountAccountSchema.extend({ type: z2.literal("discount") }), IsracardAmexAccountSchema.extend({ type: z2.literal("isracard") }), IsracardAmexAccountSchema.extend({ type: z2.literal("amex") }), CalAccountSchema.extend({ type: z2.literal("cal") }), MaxAccountSchema.extend({ type: z2.literal("max") }), OtsarHahayalAccountSchema.extend({ type: z2.literal("otsar-hahayal") }) ]); function collectSources(vault) { return [ ...vault.poalimAccounts.map((a) => ({ ...a, type: "poalim" })), ...vault.discountAccounts.map((a) => ({ ...a, type: "discount" })), ...vault.isracardAccounts.map((a) => ({ ...a, type: "isracard" })), ...vault.amexAccounts.map((a) => ({ ...a, type: "amex" })), ...vault.calAccounts.map((a) => ({ ...a, type: "cal" })), ...vault.maxAccounts.map((a) => ({ ...a, type: "max" })), ...vault.otsarHahayalAccounts.map((a) => ({ ...a, type: "otsar-hahayal" })) ]; } function appendSource(vault, source) { const { type, ...rest } = source; const v = { ...vault }; switch (type) { case "poalim": v.poalimAccounts = [...vault.poalimAccounts, rest]; break; case "discount": v.discountAccounts = [ ...vault.discountAccounts, rest ]; break; case "isracard": v.isracardAccounts = [ ...vault.isracardAccounts, rest ]; break; case "amex": v.amexAccounts = [...vault.amexAccounts, rest]; break; case "cal": v.calAccounts = [...vault.calAccounts, rest]; break; case "max": v.maxAccounts = [...vault.maxAccounts, rest]; break; case "otsar-hahayal": v.otsarHahayalAccounts = [ ...vault.otsarHahayalAccounts, rest ]; break; } return v; } var typeToKey = { poalim: "poalimAccounts", discount: "discountAccounts", isracard: "isracardAccounts", amex: "amexAccounts", cal: "calAccounts", max: "maxAccounts", "otsar-hahayal": "otsarHahayalAccounts" }; var typeToPatchSchema = { poalim: PoalimAccountSchema.omit({ id: true }).partial(), discount: DiscountAccountSchema.omit({ id: true }).partial(), isracard: IsracardAmexAccountSchema.omit({ id: true }).partial(), amex: IsracardAmexAccountSchema.omit({ id: true }).partial(), cal: CalAccountSchema.omit({ id: true }).partial(), max: MaxAccountSchema.omit({ id: true }).partial(), "otsar-hahayal": OtsarHahayalAccountSchema.omit({ id: true }).partial() }; function findSourceKey(vault, id) { for (const [type, key] of Object.entries(typeToKey)) { const arr = vault[key]; const idx = arr.findIndex((a) => a.id === id); if (idx !== -1) return { key, idx, type }; } return null; } function patchSource(vault, found, patch) { const { key, idx } = found; const arr = vault[key]; const newArr = [...arr]; newArr[idx] = { ...arr[idx], ...patch }; return { ...vault, [key]: newArr }; } function removeSource(vault, id) { const found = findSourceKey(vault, id); if (!found) return null; const { key } = found; const arr = vault[key]; return { ...vault, [key]: arr.filter((a) => a.id !== id) }; } function guardLocked3(reply) { if (isLocked()) { return reply.status(401).send({ error: "vault-locked" }); } return null; } async function registerSourcesRoutes(app) { app.get("/api/vault/sources", async (_req, reply) => { const blocked = guardLocked3(reply); if (blocked) return blocked; return collectSources(getVault()); }); app.post("/api/vault/sources", async (req, reply) => { const blocked = guardLocked3(reply); if (blocked) return blocked; const parsed = SourceConfigSchema.safeParse({ ...req.body, id: randomUUID() }); if (!parsed.success) return reply.status(400).send({ error: parsed.error.issues }); await updateVault((v) => appendSource(v, parsed.data)); return collectSources(getVault()); }); app.put( "/api/vault/sources/:id", async (req, reply) => { const blocked = guardLocked3(reply); if (blocked) return blocked; const found = findSourceKey(getVault(), req.params.id); if (!found) return reply.status(404).send({ error: "not-found" }); const parsed = typeToPatchSchema[found.type].safeParse(req.body); if (!parsed.success) return reply.status(400).send({ error: parsed.error.issues }); await updateVault((v) => patchSource(v, found, parsed.data)); return collectSources(getVault()); } ); app.delete("/api/vault/sources/:id", async (req, reply) => { const blocked = guardLocked3(reply); if (blocked) return blocked; const updated = removeSource(getVault(), req.params.id); if (!updated) return reply.status(404).send({ error: "not-found" }); await updateVault(() => updated); return collectSources(getVault()); }); } // src/server/vault-routes.ts async function registerVaultRoutes(app) { app.get("/api/vault/status", async () => ({ locked: isLocked(), hasFile: await hasVaultFile() })); app.post( "/api/vault/unlock", { schema: { body: { type: "object", required: ["password"], properties: { password: { type: "string" } } } } }, async (req, reply) => { const result = await unlockVault(req.body.password); if (result === "ok") return { ok: true }; const status = result === "not-found" ? 404 : 401; return reply.status(status).send({ error: result }); } ); app.post( "/api/vault/create", { schema: { body: { type: "object", required: ["password", "serverUrl", "apiKey"], properties: { password: { type: "string" }, serverUrl: { type: "string" }, apiKey: { type: "string" } } } } }, async (req, reply) => { if (await hasVaultFile()) return reply.status(409).send({ error: "vault-already-exists" }); const { password, serverUrl, apiKey } = req.body; lockVault(); const vault = defaultVault(); vault.settings.serverUrl = serverUrl; vault.settings.apiKey = apiKey; await saveVaultFile(getVaultPath(), vault, password); const result = await unlockVault(password); if (result !== "ok") return reply.status(500).send({ error: result }); return reply.status(201).send({ ok: true }); } ); app.get("/api/vault/env-path", async () => ({ path: getVaultPath() })); app.get("/api/vault/download", async (_req, reply) => { try { const fileBuffer = await readFile3(getVaultPath()); reply.header("Content-Type", "application/octet-stream"); reply.header("Content-Disposition", 'attachment; filename=".vault"'); return reply.send(fileBuffer); } catch (err) { if (err.code === "ENOENT") { return reply.status(404).send({ error: "no-vault-file" }); } throw err; } }); app.addContentTypeParser( "application/octet-stream", { parseAs: "buffer" }, (_req, body, done) => done(null, body) ); app.post( "/api/vault/upload", { config: { rawBody: false } }, async (req, reply) => { const force = req.query.force === "true"; const vaultPath = getVaultPath(); try { const exists = await hasVaultFile(); if (exists && !force) { return reply.status(409).send({ error: "vault-already-exists" }); } await writeFile3(vaultPath + ".tmp", req.body); await rename2(vaultPath + ".tmp", vaultPath); lockVault(); return reply.status(200).send({ ok: true }); } catch (err) { req.log.error(err, "Vault upload failed"); return reply.status(500).send({ error: "write-failed" }); } } ); app.get("/api/vault/path", async (_req, reply) => { if (isLocked()) return reply.status(401).send({ error: "vault-locked" }); return { path: getVaultPath() }; }); app.get("/api/vault/test-connection", async (_req, reply) => { if (isLocked()) return reply.status(401).send({ error: "vault-locked" }); const { serverUrl, apiKey } = getVault().settings; if (!serverUrl || !apiKey) { return reply.status(400).send({ error: "Server URL and API key are not configured" }); } const t0 = Date.now(); try { const res = await fetch(serverUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ query: "{ __typename }" }), signal: AbortSignal.timeout(1e4) }); const latencyMs = Date.now() - t0; if (!res.ok) { const text = await res.text().catch(() => res.statusText); return { ok: false, error: `HTTP ${res.status}: ${text}`, latencyMs }; } return { ok: true, latencyMs }; } catch (err) { const latencyMs = Date.now() - t0; return { ok: false, error: err instanceof Error ? err.message : String(err), latencyMs }; } }); await registerSourcesRoutes(app); await registerSettingsRoutes(app); await registerAccountsRoutes(app); } // src/server/websocket.ts import websocketPlugin from "@fastify/websocket"; // src/shared/ws-protocol.ts import { z as z3 } from "zod"; // src/shared/source-types.ts var SOURCE_TYPES = [ "poalim", "discount", "isracard", "amex", "cal", "max", "otsar-hahayal", "otsar-hahayal-credit-card" ]; // src/shared/ws-protocol.ts var CancelScrapeSchema = z3.object({ type: z3.literal("cancel-scrape") }); var PingSchema = z3.object({ type: z3.literal("ping") }); var RunStartSchema = z3.object({ type: z3.literal("run-start"), sourceIds: z3.array(z3.string()).optional(), dateFrom: z3.string().optional(), dateTo: z3.string().optional() }); var OtpSubmitSchema = z3.object({ type: z3.literal("otp-submit"), sourceId: z3.string(), otp: z3.string() }); var ClientMessageSchema = z3.discriminatedUnion("type", [ CancelScrapeSchema, PingSchema, RunStartSchema, OtpSubmitSchema ]); var ConnectedSchema = z3.object({ type: z3.literal("connected") }); var PongSchema = z3.object({ type: z3.literal("pong") }); var WsErrorSchema = z3.object({ type: z3.literal("error"), message: z3.string() }); var TaskPendingSchema = z3.object({ type: z3.literal("task-pending"), sourceId: z3.string() }); var TaskRunningSchema = z3.object({ type: z3.literal("task-running"), sourceId: z3.string() }); var InsertedTransactionSummarySchema = z3.object({ id: z3.string(), date: z3.string().nullable().optional(), description: z3.string().nullable().optional(), amount: z3.string().nullable().optional(), account: z3.string().nullable().optional() }); var ChangedFieldSchema = z3.object({ field: z3.string(), oldValue: z3.string().nullable().optional(), newValue: z3.string().nullable().optional() }); var ChangedTransactionSchema = z3.object({ id: z3.string(), changedFields: z3.array(ChangedFieldSchema) }); var TaskDoneSchema = z3.object({ type: z3.literal("task-done"), sourceId: z3.string(), inserted: z3.number(), skipped: z3.number(), insertedIds: z3.array(z3.string()), insertedTransactions: z3.array(InsertedTransactionSummarySchema).optional(), changedTransactions: z3.array(ChangedTransactionSchema).optional() }); var TaskErrorSchema = z3.object({ type: z3.literal("task-error"), sourceId: z3.string(), message: z3.string(), stack: z3.string().optional() }); var TaskBlockedSchema = z3.object({ type: z3.literal("task-blocked"), sourceId: z3.string(), sourceType: z3.enum(SOURCE_TYPES), unknownAccounts: z3.array(z3.string()) }); var OtpRequiredSchema = z3.object({ type: z3.literal("otp-required"), sourceId: z3.string(), prompt: z3.string().optional() }); var RunCompleteSchema = z3.object({ type: z3.literal("run-complete"), totalInserted: z3.number(), totalSkipped: z3.number(), errors: z3.number() }); var RunErrorSchema = z3.object({ type: z3.literal("run-error"), message: z3.string() }); var TaskMonthFetchingSchema = z3.object({ type: z3.literal("task-month-fetching"), sourceId: z3.string(), month: z3.string() }); var TaskMonthFetchedSchema = z3.object({ type: z3.literal("task-month-fetched"), sourceId: z3.string(), month: z3.string(), transactionCount: z3.number() }); var TaskMonthErrorSchema = z3.object({ type: z3.literal("task-month-error"), sourceId: z3.string(), month: z3.string(), error: z3.string() }); var TaskMonthUploadingSchema = z3.object({ type: z3.literal("task-month-uploading"), sourceId: z3.string(), month: z3.string(), transactionCount: z3.number() }); var TaskMonthUploadedSchema = z3.object({ type: z3.literal("task-month-uploaded"), sourceId: z3.string(), month: z3.string(), inserted: z3.number(), skipped: z3.number() }); var TaskAccountsFoundSchema = z3.object({ type: z3.literal("task-accounts-found"), sourceId: z3.string(), accounts: z3.array( z3.object({ accountNumber: z3.string(), branchNumber: z3.number(), bankNumber: z3.number() }) ) }); var TaskAccountVaultCheckedSchema = z3.object({ type: z3.literal("task-account-vault-checked"), sourceId: z3.string(), accountId: z3.string(), status: z3.enum(["accepted", "ignored", "blocked", "unknown"]) }); var TaskAccountTxnsFetchingSchema = z3.object({ type: z3.literal("task-account-txns-fetching"), sourceId: z3.string(), accountId: z3.string(), txnType: z3.enum(["ils", "foreign", "swift"]) }); var TaskAccountTxnsUploadingSchema = z3.object({ type: z3.literal("task-account-txns-uploading"), sourceId: z3.string(), accountId: z3.string(), txnType: z3.enum(["ils", "foreign", "swift"]), count: z3.number() }); var TaskAccountTxnsDoneSchema = z3.object({ type: z3.literal("task-account-txns-done"), sourceId: z3.string(), accountId: z3.string(), txnType: z3.enum(["ils", "foreign", "swift"]), inserted: z3.number(), skipped: z3.number() }); var ServerMessageSchema = z3.discriminatedUnion("type", [ ConnectedSchema, PongSchema, WsErrorSchema, TaskPendingSchema, TaskRunningSchema, TaskDoneSchema, TaskErrorSchema, TaskBlockedSchema, OtpRequiredSchema, RunCompleteSchema, RunErrorSchema, TaskMonthFetchingSchema, TaskMonthFetchedSchema, TaskMonthErrorSchema, TaskMonthUploadingSchema, TaskMonthUploadedSchema, TaskAccountsFoundSchema, TaskAccountVaultCheckedSchema, TaskAccountTxnsFetchingSchema, TaskAccountTxnsUploadingSchema, TaskAccountTxnsDoneSchema ]); // src/server/account-discovery.ts import { randomUUID as randomUUID2 } from "crypto"; // src/server/check-accounts.ts function extractAccountIdentifiers(type, payload) { switch (type) { case "poalim": { const p = payload; if (!("accountNumber" in p) || !("branchNumber" in p)) { return []; } return [`${p.branchNumber}-${p.accountNumber}`]; } case "discount": { const p = payload; return [...new Set(p.map((entry) => entry.accountNumber))]; } case "isracard": case "amex": { const p = payload; return p.CardsTransactionsListBean.cardNumberList.map((c) => c.match(/\d{4}/)?.[0]).filter((c) => c !== void 0); } case "cal": { const p = payload; return [...new Set(p.map((entry) => entry.card))]; } case "max": { const p = payload; return [...new Set(p.map((account) => account.accountNumber))]; } case "otsar-hahayal": { const p = payload; return [...new Set(p.map((a) => `${a.branch}-${a.account}`))]; } case "otsar-hahayal-credit-card": { const p = payload; return [...new Set(p.map(({ card }) => card.maskedPan))]; } } } function checkAccounts(type, payload, known) { const identifiers = extractAccountIdentifiers(type, payload); const accepted = []; const ignored = []; const unknown = []; for (const id of identifiers) { const record = known.find((a) => a.sourceType === type && a.accountNumber === id); if (!record) { unknown.push(id); } else if (record.status === "ignored") { ignored.push(id); } else if (record.status === "accepted") { accepted.push(id); } else { unknown.push(id); } } return { accepted, ignored, unknown }; } // src/server/account-discovery.ts async function registerDiscoveredAccounts(sourceType, sourceId, payloads) { const existing = getVault().accountRecords; const newRecords = []; for (const payload of payloads) { for (const accountNumber of extractAccountIdentifiers(sourceType, payload)) { const alreadyKnown = existing.some( (r) => r.sourceType === sourceType && r.accountNumber === accountNumber ); if (!alreadyKnown && !newRecords.some( (r) => r.sourceType === sourceType && r.sourceId === sourceId && r.accountNumber === accountNumber )) { newRecords.push({ id: randomUUID2(), sourceId, sourceType, accountNumber, status: "pending" }); } } } if (newRecords.length > 0) { await updateVault((v) => ({ ...v, accountRecords: [...v.accountRecords, ...newRecords] })); } } // src/server/filter-payload.ts function effectiveSet(accepted, ignored, all) { const base = accepted?.length ? accepted : all; const ignoredSet = new Set(ignored ?? []); return new Set(base.filter((c) => !ignoredSet.has(c))); } function filterIsracardAmex(payload, accepted, ignored) { const bean = payload.CardsTransactionsListBean; const allCards = bean.cardNumberList.map((c) => c.match(/\d{4}/)?.[0]); const allowed = effectiveSet(accepted, ignored, allCards.filter(Boolean)); const filteredBean = {}; for (const [key, val] of Object.entries(bean)) { if (/^Index\d+$/.test(key)) { const card = allCards[Number(key.slice(5))]; if (!card || !allowed.has(card)) { continue; } } filteredBean[key] = val; } return { ...payload, CardsTransactionsListBean: filteredBean }; } function filterPayload(type, payload, creds) { switch (type) { case "isracard": case "amex": { const p = payload; const opts = creds.options; return filterIsracardAmex( p, opts?.acceptedCardNumbers, opts?.ignoredCardNumbers ); } case "cal": { const p = payload; const opts = creds.options; const allCards = [...new Set(p.map((e) => e.card))]; const allowed = effectiveSet(opts?.acceptedCardNumbers, opts?.ignoredCardNumbers, allCards); return p.filter((e) => allowed.has(e.card)); } case "max": { const p = payload; const opts = creds.options; const allAccounts = [...new Set(p.map((e) => e.accountNumber))]; const allowed = effectiveSet( opts?.acceptedCardNumbers, opts?.ignoredCardNumbers, allAccounts ); return p.filter((e) => allowed.has(e.accountNumber)); } case "poalim": { const opts = creds.options; if ("retrievalTransactionData" in payload) { const p = payload; const { accountNumber, branchNumber } = p.retrievalTransactionData; 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))) { return { ...p, transactions: [] }; } return p; } if ("balancesAndLimitsDataList" in payload) { return payload; } if ("swiftsList" in payload) { return payload; } return payload; } case "discount": { const p = payload; return p; } } } // src/server/graphql/client.ts import { GraphQLClient } from "graphql-request"; // src/server/utils.ts function convertNumberDateToString(numberDate) { if (numberDate < 1e7 || numberDate > 99999999) { throw new Error("Invalid number date"); } const stringDate = numberDate.toString(); const year = stringDate.substring(0, 4); const month = stringDate.substring(4, 6); const day = stringDate.substring(6, 8); return `${year}-${month}-${day}`; } function convertPoalimCurrencyCodeToSymbol(code) { switch (code) { case 19: return "USD"; case 27: return "GBP"; case 36: return "AUD"; case 51: return "SEK"; case 100: return "EUR"; case 140: return "CAD"; case 248: return "JPY"; default: throw new Error(`New Poalim account currency - ${code}`); } } function getPoalimSwiftCodeExtensions(swiftTransferDetailsList, index) { if (index >= swiftTransferDetailsList.length) { return ""; } if (swiftTransferDetailsList[index].swiftTransferAttributeCode !== null) { return ""; } const value = swiftTransferDetailsList[index].swiftTransferAttributeValue; return ` ${value}${getPoalimSwiftCodeExtensions(swiftTransferDetailsList, index + 1)}`; } function findPoalimSwiftElement(transaction, attribute, defaultEmptyString = false) { const emptyValue = defaultEmptyString ? " " : null; const index = transaction.swiftTransferDetailsList.findIndex( (element) => element.swiftTransferAttributeCode === attribute ); if (index === -1) { return emptyValue; } let value = transaction.swiftTransferDetailsList[index].swiftTransferAttributeValue; if (value === null || value === void 0) { return emptyValue; } value += getPoalimSwiftCodeExtensions(transaction.swiftTransferDetailsList, index + 1); return value; } // src/server/graphql/mutations.ts var UPLOAD_POALIM_ILS = ( /* GraphQL */ ` mutation UploadPoalimIlsTransactions($transactions: [PoalimIlsTransactionInput!]!) { uploadPoalimIlsTransactions(transactions: $transactions) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_POALIM_FOREIGN = ( /* GraphQL */ ` mutation UploadPoalimForeignTransactions($transactions: [PoalimForeignTransactionInput!]!) { uploadPoalimForeignTransactions(transactions: $transactions) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_POALIM_SWIFT = ( /* GraphQL */ ` mutation UploadPoalimSwiftTransactions($swifts: [PoalimSwiftTransactionInput!]!) { uploadPoalimSwiftTransactions(swifts: $swifts) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_ISRACARD = ( /* GraphQL */ ` mutation UploadIsracardTransactions($transactions: [IsracardTransactionInput!]!) { uploadIsracardTransactions(transactions: $transactions) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_AMEX = ( /* GraphQL */ ` mutation UploadAmexTransactions($transactions: [AmexTransactionInput!]!) { uploadAmexTransactions(transactions: $transactions) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_CAL = ( /* GraphQL */ ` mutation UploadCalTransactions($transactions: [CalTransactionInput!]!) { uploadCalTransactions(transactions: $transactions) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_DISCOUNT = ( /* GraphQL */ ` mutation UploadDiscountTransactions($transactions: [DiscountTransactionInput!]!) { uploadDiscountTransactions(transactions: $transactions) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_MAX = ( /* GraphQL */ ` mutation UploadMaxTransactions($transactions: [MaxTransactionInput!]!) { uploadMaxTransactions(transactions: $transactions) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_CURRENCY_RATES = ( /* GraphQL */ ` mutation UploadCurrencyRates($rates: [CurrencyRateInput!]!) { uploadCurrencyRates(rates: $rates) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_OTSAR_ILS = ( /* GraphQL */ ` mutation UploadOtsarHahayalIlsTransactions($transactions: [OtsarHahayalIlsTransactionInput!]!) { uploadOtsarHahayalIlsTransactions(transactions: $transactions) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_OTSAR_FOREIGN = ( /* GraphQL */ ` mutation UploadOtsarHahayalForeignTransactions( $transactions: [OtsarHahayalForeignTransactionInput!]! ) { uploadOtsarHahayalForeignTransactions(transactions: $transactions) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); var UPLOAD_OTSAR_CREDITCARD = ( /* GraphQL */ ` mutation UploadOtsarHahayalCreditCardTransactions( $transactions: [OtsarHahayalCreditCardTransactionInput!]! ) { uploadOtsarHahayalCreditCardTransactions(transactions: $transactions) { inserted skipped insertedIds insertedTransactions { id date description amount account } changedTransactions { id changedFields { field oldValue newValue } } } } ` ); function poalimIlsVars(payload) { const { accountNumber, branchNumber, bankNumber } = payload.retrievalTransactionData; const transactions = payload.transactions.map((t) => { const beneficiaryDetails = t.beneficiaryDetailsData ? { beneficiaryDetailsData: null, // InputMaybe<Scalars['String']['input']>; OPTIONAL beneficiaryDetailsDataMessageDetail: t.beneficiaryDetailsData?.messageDetail, // InputMaybe<Scalars['String']['input']>; OPTIONAL beneficiaryDetailsDataMessageHeadline: t.beneficiaryDetailsData?.messageHeadline, // InputMaybe<Scalars['String']['input']>; OPTIONAL beneficiaryDetailsDataPartyHeadline: t.beneficiaryDetailsData?.partyHeadline, // InputMaybe<Scalars['String']['input']>; OPTIONAL beneficiaryDetailsDataPartyName: t.beneficiaryDetailsData?.partyName, // InputMaybe<Scalars['String']['input']>; OPTIONAL beneficiaryDetailsDataRecordNumber: t.beneficiaryDetailsData?.recordNumber, // InputMaybe<Scalars['Int']['input']>; OPTIONAL beneficiaryDetailsDataTableNumber: t.beneficiaryDetailsData?.tableNumber // InputMaybe<Scalars['Int']['input']>; OPTIONAL } : {}; return { accountNumber, activityDescription: t.activityDescription, // Scalars['String']['input']; activityDescriptionIncludeValueDate: t.activityDescriptionIncludeValueDate, // InputMaybe<Scalars['String']['input']>; OPTIONAL activityTypeCode: t.activityTypeCode, // Scalars['Int']['input']; bankNumber, ...beneficiaryDetails, branchNumber, comment: t.comment, commentExistenceSwitch: t.commentExistenceSwitch !== 0, contraAccountNumber: t.contraAccountNumber, contraAccountTypeCode: t.contraAccountTypeCode, contraBankNumber: t.contraBankNumber, contraBranchNumber: t.contraBranchNumber, currentBalance: t.currentBalance, dataGroupCode: t.dataGroupCode, details: t.details, differentDateIndication: t.differentDateIndication, englishActionDesc: t.englishActionDesc, eventActivityTypeCode: t.eventActivityTypeCode, eventAmount: t.eventAmount, eventDate: convertNumberDateToString(t.eventDate), eventId: String(t.eventId), executingBranchNumber: t.executingBranchNumber, expandedEventDate: t.expandedEventDate, fieldDescDisplaySwitch: t.fieldDescDisplaySwitch === 1, formattedEventDate: t.formattedEventDate, formattedOriginalEventCreateDate: t.formattedOriginalEventCreateDate, formattedValueDate: t.formattedValueDate, internalLinkCode: t.internalLinkCode, marketingOfferContext: t.marketingOfferContext !== 0, offerActivityContext: t.offerActivityContext, originalEventCreateDate: t.originalEventCreateDate, pfmDetails: t.pfmDetails, recordNumber: t.recordNumber, referenceCatenatedNumber: t.referenceCatenatedNumber, referenceNumber: String(t.referenceNumber), rejectedDataEventPertainingIndication: t.rejectedDataEventPertainingIndication, serialNumber: t.serialNumber, tableNumber: t.tableNumber, textCode: t.textCode, transactionType: t.transactionType, valueDate: convertNumberDateToString(t.valueDate) }; }); return { transactions }; } function poalimForeignVars(payload, account) { const transactions = payload.balancesAndLimitsDataList.flatMap((entry) => { const currency = convertPoalimCurrencyCodeToSymbol(entry.currencyCode); const bankNumber = "bankNumber" in entry ? entry.bankNumber : account.bankNumber; const branchNumber = "branchNumber" in entry ? entry.branchNumber : account.branchNumber; const accountNumber = "accountNumber" in entry ? entry.accountNumber : account.accountNumber; return entry.transactions.map((t) => { const metadataAttributes = {}; if (t.metadata) { if (t.metadata.attributes) { metadataAttributes.metadataAttributesOriginalEventKey = JSON.stringify( t.metadata.attributes.originalEventKey ); metadataAttributes.metadataAttributesContraBranchNumber = JSON.stringify( t.metadata.attributes.contraBranchNumber ); metadataAttributes.metadataAttributesContraAccountNumber = JSON.stringify( t.metadata.attributes.contraAccountNumber ); metadataAttributes.metadataAttributesContraBankNumber = JSON.stringify( t.metadata.attributes.contraBankNumber ); metadataAttributes.metadataAttributesContraAccountFieldNameLable = JSON.stringify( t.metadata.attributes.contraAccountFieldNameLable ); metadataAttributes.metadataAttributesDataGroupCode = JSON.stringify( t.metadata.attributes.dataGroupCode ); metadataAttributes.metadataAttributesCurrencyRate = JSON.stringify( t.metadata.attributes.currencyRate ); metadataAttributes.metadataAttributesContraCurrencyCode = JSON.stringify( t.metadata.attributes.contraCurrencyCode ); metadataAttributes.metadataAttributesRateFixingCode = JSON.stringify( t.metadata.attributes.rateFixingCode ); } else { metadataAttributes.metadataAttributesContraAccountFieldNameLable = null; metadataAttributes.metadataAttributesContraAccountNumber = null; metadataAttributes.metadataAttributesContraBankNumber = null; metadataAttributes.metadataAttributesContraBranchNumber = null; metadataAttributes.metadataAttributesContraCurrencyCode = null; metadataAttributes.metadataAttributesCurrencyRate = null; metadataAttributes.metadataAttributesDataGroupCode = null; metadataAttributes.metadataAttributesOriginalEventKey = null; metadataAttributes.metadataAttributesRateFixingCode = null; } } return { accountName: "accountName" in t ? t.accountName : null, accountNumber, activityDescription: t.activityDescription, activityTypeCode: t.activityTypeCode, bankNumber, branchNumber, commentExistenceSwitch: "commentExistenceSwitch" in t ? t.commentExistenceSwitch !== 0 : false, comments: "comments" in t ? t.comments : null, contraAccountFieldNameLable: "contraAccountFieldNameLable" in t ? t.contraAccountFieldNameLable : null, contraAccountNumber: "contraAccountNumber" in t ? t.contraAccountNumber : 0, contraBankNumber: "contraBankNumber" in t ? t.contraBankNumber : 0, contraBranchNumber: "contraBranchNumber" in t ? t.contraBranchNumber : 0, contraCurrencyCode: t.contraCurrencyCode, currency, currencyLongDescription: t.currencyLongDescription, currencyRate: t.currencyRate, currencySwiftCode: t.currencySwiftCode, currentBalance: t.currentBalance, dataGroupCode: "dataGroupCode" in t ? t.dataGroupCode !== 0 : false, eventActivityTypeCode: t.eventActivityTypeCode, eventAmount: t.eventAmount, eventDetails: t.eventDetails, eventNumber: t.eventNumber, executingDate: convertNumberDateToString(t.executingDate), formattedExecutingDate: t.formattedExecutingDate, formattedValueDate: t.formattedValueDate, ...metadataAttributes, originalEventKey: "originalEventKey" in t ? t.originalEventKey !== 0 : false, originalSystemId: t.originalSystemId, rateFixingCode: t.rateFixingCode, rateFixingDescription: t.rateFixingDescription, rateFixingShortDescription: t.rateFixingShortDescription, referenceCatenatedNumber: t.referenceCatenatedNumber, referenceNumber: String(t.referenceNumber), transactionType: t.transactionType, urlAddress: t.urlAddress, validityDate: convertNumberDateToString(t.validityDate), valueDate: convertNumberDateToString(t.valueDate) }; }); }); return { transactions }; } function poalimSwiftVars(payload, bankAccount) { const swifts = payload.swiftsList.map((s) => ({ accountNumber: bankAccount.accountNumber, branchNumber: bankAccount.branchNumber, bankNumber: bankAccount.bankNumber, startDate: s.startDate.toString(), formattedStartDate: s.fo