@accounter/scraper-app
Version:
Scraper app with Fastify server and React UI
1,619 lines (1,598 loc) • 125 kB
JavaScript
// 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