mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 11:06:40 +07:00
Compare commits
6 Commits
14948aff1d
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ff962f137 | ||
|
|
64a190678e | ||
|
|
0cf72831a8 | ||
|
|
848ff21665 | ||
|
|
c867710f38 | ||
|
|
3bd484b88e |
12
DOCS.md
12
DOCS.md
@@ -368,6 +368,16 @@ Used by the CSV import wizard, but callable directly for any bulk load (e.g. syn
|
||||
export tool). All rows are validated **before** any is inserted — one bad row fails the whole
|
||||
request with no partial import.
|
||||
|
||||
The wizard itself (`/transactions/import`) is bank-agnostic: it auto-detects delimiter and
|
||||
encoding and lets you map columns by hand. On top of that it ships **bank profiles** that pre-fill
|
||||
the mapping and formats when the header row matches a known export (or when you pick the bank
|
||||
explicitly): **T-Bank** (semicolon CSV, `Сумма платежа`, rows with `Статус ≠ OK` skipped),
|
||||
**monobank** (Ukrainian-language CSV, `Сума в валюті картки (…)`) and **PrivatBank**
|
||||
(`Сума в валюті картки`, `Опис операції`). Profiles live in `frontend/src/lib/bankPresets.ts` —
|
||||
adding a bank is one declarative entry (header matchers, date/amount format, optional row filter).
|
||||
Banks that only export PDF/XLSX (Sber, Kaspi, Halyk, maib…) are imported via a third-party
|
||||
PDF→CSV converter and the manual mapping.
|
||||
|
||||
```json
|
||||
{ "items": [ /* 1–5000 TransactionCreate objects */ ] }
|
||||
```
|
||||
@@ -768,7 +778,7 @@ Single row, created automatically on first run — there's nothing to create, on
|
||||
"risky_allocation_threshold_percent": 20,
|
||||
"idle_cash_threshold_amount": "1000.00",
|
||||
"idle_cash_threshold_days": 60,
|
||||
"app_version": "1.1.7"
|
||||
"app_version": "1.1.8"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Aurum treats your financial life as a **system**, not a spreadsheet.
|
||||
## 🧩 Core Features
|
||||
|
||||
### 💰 Cash Flow & Transactions
|
||||
Log income, expenses, and transfers across as many accounts as you want, organized into categories (with one level of subcategories) plus free-form tags, and searchable. One purchase spanning multiple subcategories of the same parent (a grocery receipt part "Sweets", part "Alcohol") can be recorded as a single split transaction instead of several — every report and chart still counts each share under its own category. Already have history elsewhere? Import a bank's CSV export in a guided 3-step wizard instead of typing every line by hand. A dedicated Cash Flow view charts income vs. expense month by month.
|
||||
Log income, expenses, and transfers across as many accounts as you want, organized into categories (with one level of subcategories) plus free-form tags, and searchable. One purchase spanning multiple subcategories of the same parent (a grocery receipt part "Sweets", part "Alcohol") can be recorded as a single split transaction instead of several — every report and chart still counts each share under its own category. Already have history elsewhere? Import a bank's CSV export in a guided 3-step wizard instead of typing every line by hand — with built-in profiles for T-Bank, monobank and PrivatBank exports (columns, date/amount formats and declined-payment rows handled automatically), and manual column mapping for everything else. A dedicated Cash Flow view charts income vs. expense month by month.
|
||||
|
||||
### 📈 Net Worth Engine
|
||||
A live net worth timeline (30 days to all-time) aggregating cash and every manually tracked asset — investments, crypto, real estate, vehicles, precious metals — into one number, with a full breakdown by asset class, by how each asset behaves (income / neutral / drain), and by risk level.
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
# it in Settings; it deliberately does NOT ride on the unauthenticated
|
||||
# /api/health), and embedded in exported backups so an old file can be told
|
||||
# apart from a current one.
|
||||
APP_VERSION = "1.1.7"
|
||||
APP_VERSION = "1.1.8"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
# backend container (see backend/tests/README.md) rather than baking these
|
||||
# into the Docker image.
|
||||
-r requirements.txt
|
||||
pytest==9.0.3
|
||||
pytest==9.1.1
|
||||
pytest-asyncio==1.4.0
|
||||
httpx==0.28.1
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
fastapi==0.141.1
|
||||
uvicorn[standard]==0.52.4
|
||||
sqlalchemy==2.0.52
|
||||
fastapi==0.142.2
|
||||
uvicorn[standard]==0.54.0
|
||||
sqlalchemy==2.1.3
|
||||
asyncpg==0.31.0
|
||||
alembic==1.19.2
|
||||
alembic==1.20.0
|
||||
pydantic==2.13.5
|
||||
pydantic-settings==2.15.0
|
||||
python-dotenv==1.2.3
|
||||
python-dotenv==1.2.4
|
||||
httpx==0.28.1
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b0b0b" />
|
||||
<title>Aurum — Личные финансы</title>
|
||||
<title>Aurum — Personal Finance</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Aurum is a self-hosted personal finance operating system: cash flow, net worth, budgets, subscriptions, assets, and every income stream in one place."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="personal finance, finance tracker, budgeting, self-hosted, cash flow, net worth, expense tracker, subscription tracker, crypto tracker, family budget"
|
||||
/>
|
||||
<meta name="author" content="Aurum" />
|
||||
<!-- Self-hosted app without a fixed public domain, so og:url/og:image
|
||||
are left out — each deployment would need its own values. -->
|
||||
<meta property="og:title" content="Aurum — Personal Finance" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A self-hosted personal finance operating system: cash flow, net worth, budgets, subscriptions, and assets."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Aurum — Personal Finance" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="A self-hosted personal finance operating system: cash flow, net worth, budgets, subscriptions, and assets."
|
||||
/>
|
||||
<!-- Runs before the first paint to avoid a wrong-theme flash. Kept in its
|
||||
own file (public/theme-init.js), not inline, so the CSP in
|
||||
frontend/nginx.conf can forbid inline scripts entirely. -->
|
||||
|
||||
264
frontend/package-lock.json
generated
264
frontend/package-lock.json
generated
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"name": "aurum-frontend",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aurum-frontend",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.31.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"lucide-react": "^1.45.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
},
|
||||
@@ -285,13 +285,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.144.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
|
||||
"integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
|
||||
"version": "0.149.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
|
||||
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
@@ -320,10 +320,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
|
||||
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -338,9 +355,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -355,9 +372,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
|
||||
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -372,9 +389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
|
||||
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -389,9 +406,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
|
||||
"integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
|
||||
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -406,9 +423,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -426,9 +443,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
|
||||
"integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
|
||||
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -446,9 +463,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -466,9 +483,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -486,9 +503,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -506,9 +523,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
|
||||
"integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
|
||||
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -526,9 +543,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -543,9 +560,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
|
||||
"integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
|
||||
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -560,9 +577,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
|
||||
"integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
|
||||
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -946,9 +963,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
|
||||
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
|
||||
"version": "5.102.8",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz",
|
||||
"integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -956,12 +973,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
|
||||
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
|
||||
"version": "5.102.8",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz",
|
||||
"integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.4"
|
||||
"@tanstack/query-core": "5.102.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -1060,19 +1077,19 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
|
||||
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
|
||||
"version": "26.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
|
||||
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1080,13 +1097,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
@@ -2244,9 +2261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz",
|
||||
"integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==",
|
||||
"version": "1.45.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.45.0.tgz",
|
||||
"integrity": "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -2270,9 +2287,9 @@
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2336,9 +2353,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2356,7 +2373,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.17",
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -2375,24 +2392,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
"scheduler": "^0.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.8"
|
||||
"react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
@@ -2525,13 +2542,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
|
||||
"integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
|
||||
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.144.0",
|
||||
"@oxc-project/types": "=0.149.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2541,20 +2558,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.2.4",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.4",
|
||||
"@rolldown/binding-darwin-x64": "1.2.4",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.4",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.4",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.4",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.4",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.4",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.4"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.8",
|
||||
"@rolldown/binding-android-arm64": "1.2.8",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.8",
|
||||
"@rolldown/binding-darwin-x64": "1.2.8",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.8",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.8",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.8",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.8",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
@@ -2571,9 +2589,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
@@ -2786,9 +2804,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2824,16 +2842,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
|
||||
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.26",
|
||||
"rolldown": "~1.2.4",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.28",
|
||||
"rolldown": "~1.2.6",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2850,7 +2868,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
|
||||
"@vitejs/devtools": "^0.7.1",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aurum-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.7",
|
||||
"version": "1.1.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,25 +11,25 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.31.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"lucide-react": "^1.45.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
119
frontend/src/lib/bankPresets.test.ts
Normal file
119
frontend/src/lib/bankPresets.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPresetMapping, detectPreset, findPreset, normalizeHeader, rowFilterRejection, type ImportMapping } from "@/lib/bankPresets";
|
||||
import { parseAmount, parseCsv, parseDateWithFormat } from "@/lib/csv";
|
||||
|
||||
const EMPTY: ImportMapping = { date: "", amount: "", description: "", merchant: "", notes: "", category: "" };
|
||||
|
||||
// Header rows as the banks actually emit them (see the per-preset comments in
|
||||
// lib/bankPresets.ts for where each export comes from).
|
||||
const TBANK_CSV = [
|
||||
'"Дата операции";"Дата платежа";"Номер карты";"Статус";"Сумма операции";"Валюта операции";"Сумма платежа";"Валюта платежа";"Кэшбэк";"Категория";"MCC";"Описание";"Бонусы (включая кэшбэк)"',
|
||||
'"24.10.2018 17:04:25";"24.10.2018";"*8305";"OK";"-882,04";"RUB";"-882,04";"RUB";"";"Переводы/иб";"";"Пополнение счета Тинькофф Брокер";"0,00"',
|
||||
'"25.10.2018 09:12:00";"25.10.2018";"*8305";"FAILED";"-1500,00";"RUB";"-1500,00";"RUB";"";"Супермаркеты";"5411";"Пятёрочка";"0,00"',
|
||||
].join("\n");
|
||||
|
||||
const MONOBANK_CSV = [
|
||||
// Latin "i" in "Дата i час" is deliberate — that's what the real file has.
|
||||
'"Дата i час операції","Деталі операції",MCC,"Сума в валюті картки (UAH)","Сума в валюті операції",Валюта,Курс,"Сума комісій (UAH)","Сума кешбеку (UAH)","Залишок після операції"',
|
||||
'"01.03.2024 12:30:45","Сільпо",5411,-250.50,-250.50,UAH,—,—,2.50,10000.00',
|
||||
].join("\n");
|
||||
|
||||
const PRIVAT_CSV = [
|
||||
"Дата;Час;Категорія;Картка;Опис операції;Сума в валюті картки;Валюта картки;Сума в валюті транзакції;Валюта транзакції;Залишок на кінець періоду;Валюта залишку",
|
||||
"05.02.2024;14:05;Продукти;5168****1234;АТБ;-320,00;UAH;-320,00;UAH;1500,00;UAH",
|
||||
].join("\n");
|
||||
|
||||
describe("normalizeHeader", () => {
|
||||
it("folds case, whitespace, BOM and the Latin i", () => {
|
||||
expect(normalizeHeader(" Дата i час ")).toBe("дата і час");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectPreset", () => {
|
||||
it("recognises T-Bank, monobank and PrivatBank header rows", () => {
|
||||
expect(detectPreset(parseCsv(TBANK_CSV)[0])?.id).toBe("tbank");
|
||||
expect(detectPreset(parseCsv(MONOBANK_CSV)[0])?.id).toBe("monobank");
|
||||
expect(detectPreset(parseCsv(PRIVAT_CSV)[0])?.id).toBe("privatbank");
|
||||
});
|
||||
|
||||
it("returns null for a generic export", () => {
|
||||
expect(detectPreset(["Date", "Amount", "Description"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPresetMapping", () => {
|
||||
it("maps T-Bank onto the card-currency amount and keeps the category", () => {
|
||||
const headers = parseCsv(TBANK_CSV)[0];
|
||||
const mapping = applyPresetMapping(findPreset("tbank")!, headers, EMPTY);
|
||||
expect(mapping).toMatchObject({ date: "Дата операции", amount: "Сумма платежа", description: "Описание", category: "Категория" });
|
||||
});
|
||||
|
||||
it("matches monobank's currency-suffixed amount header by prefix", () => {
|
||||
const headers = parseCsv(MONOBANK_CSV)[0];
|
||||
const mapping = applyPresetMapping(findPreset("monobank")!, headers, EMPTY);
|
||||
expect(mapping.amount).toBe("Сума в валюті картки (UAH)");
|
||||
expect(mapping.date).toBe("Дата i час операції");
|
||||
});
|
||||
|
||||
it("leaves fields the preset doesn't cover on the fallback", () => {
|
||||
const headers = parseCsv(PRIVAT_CSV)[0];
|
||||
const mapping = applyPresetMapping(findPreset("privatbank")!, headers, { ...EMPTY, notes: "Картка" });
|
||||
expect(mapping.notes).toBe("Картка");
|
||||
expect(mapping.category).toBe("Категорія");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rowFilterRejection", () => {
|
||||
it("drops T-Bank rows whose status is not OK and reports the status", () => {
|
||||
const [headers, ok, failed] = parseCsv(TBANK_CSV);
|
||||
const preset = findPreset("tbank")!;
|
||||
expect(rowFilterRejection(preset, headers, ok)).toBeNull();
|
||||
expect(rowFilterRejection(preset, headers, failed)).toBe("FAILED");
|
||||
});
|
||||
|
||||
it("keeps everything when the preset has no filter or the column is absent", () => {
|
||||
const [headers, row] = parseCsv(MONOBANK_CSV);
|
||||
expect(rowFilterRejection(findPreset("monobank")!, headers, row)).toBeNull();
|
||||
expect(rowFilterRejection(findPreset("tbank")!, ["Дата"], ["01.01.2024"])).toBeNull();
|
||||
expect(rowFilterRejection(null, headers, row)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("preset formats against real sample rows", () => {
|
||||
it("parses T-Bank's timestamped date and comma-decimal amount", () => {
|
||||
const [, row] = parseCsv(TBANK_CSV);
|
||||
const preset = findPreset("tbank")!;
|
||||
expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2018-10-24");
|
||||
expect(parseAmount(row[6], preset.amountFormat)).toBe(-882.04);
|
||||
});
|
||||
|
||||
it("parses monobank's timestamped date and dot-decimal amount", () => {
|
||||
const [, row] = parseCsv(MONOBANK_CSV);
|
||||
const preset = findPreset("monobank")!;
|
||||
expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2024-03-01");
|
||||
expect(parseAmount(row[3], preset.amountFormat)).toBe(-250.5);
|
||||
// monobank writes empty cells as an em dash — must not parse as 0.
|
||||
expect(parseAmount(row[6], preset.amountFormat)).toBeNull();
|
||||
});
|
||||
|
||||
it("parses PrivatBank's plain date and comma amount", () => {
|
||||
const [, row] = parseCsv(PRIVAT_CSV);
|
||||
const preset = findPreset("privatbank")!;
|
||||
expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2024-02-05");
|
||||
expect(parseAmount(row[5], preset.amountFormat)).toBe(-320);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDateWithFormat with a time suffix", () => {
|
||||
it("accepts an optional trailing time in every format", () => {
|
||||
expect(parseDateWithFormat("2024-01-05T09:30", "YYYY-MM-DD")).toBe("2024-01-05");
|
||||
expect(parseDateWithFormat("05/01/2024 09:30:15", "DD/MM/YYYY")).toBe("2024-01-05");
|
||||
expect(parseDateWithFormat("01/05/2024 9:30", "MM/DD/YYYY")).toBe("2024-01-05");
|
||||
expect(parseDateWithFormat("05-01-2024 09:30", "DD-MM-YYYY")).toBe("2024-01-05");
|
||||
});
|
||||
|
||||
it("still rejects trailing garbage that isn't a time", () => {
|
||||
expect(parseDateWithFormat("05.01.2024 abc", "DD.MM.YYYY")).toBeNull();
|
||||
expect(parseDateWithFormat("05.01.2024 09", "DD.MM.YYYY")).toBeNull();
|
||||
});
|
||||
});
|
||||
152
frontend/src/lib/bankPresets.ts
Normal file
152
frontend/src/lib/bankPresets.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Bank-specific import presets for the CSV wizard (pages/CsvImportPage.tsx).
|
||||
//
|
||||
// A preset is nothing more than a pre-filled answer to the wizard's "map"
|
||||
// step: which column is the date, which is the amount, what the date/amount
|
||||
// formats are, and — the one thing the generic mapping can't express — which
|
||||
// rows to drop (a declined card payment still shows up in the export). The
|
||||
// wizard itself stays bank-agnostic: presets only feed it defaults, and the
|
||||
// user can still override any of them on the map screen.
|
||||
//
|
||||
// Only banks whose personal-account CSV export has a known, stable header
|
||||
// layout are listed. Banks that export PDF/XLSX only (Sber, Kaspi, Halyk,
|
||||
// maib…) go through the generic mapping via a third-party converter instead.
|
||||
import type { AmountFormat, DateFormat } from "@/lib/csv";
|
||||
|
||||
/** Column mapping as the wizard understands it — header *names*, not
|
||||
* indexes, so re-decoding the file under another encoding can re-resolve
|
||||
* them. Empty string = not mapped. */
|
||||
export interface ImportMapping {
|
||||
date: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
merchant: string;
|
||||
notes: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/** Matches a header cell by normalised prefix — see normalizeHeader(). A
|
||||
* prefix, not equality, because some banks put the account currency in the
|
||||
* header itself ("Сума в валюті картки (UAH)") and it changes per card. */
|
||||
export type HeaderMatcher = string[];
|
||||
|
||||
export interface BankPreset {
|
||||
id: string;
|
||||
/** Proper name, shown as-is — bank names aren't translated. */
|
||||
label: string;
|
||||
/** Every one of these must be present for detectPreset() to pick this
|
||||
* preset. Keep it to the columns the mapping actually needs, so a bank
|
||||
* adding a trailing column doesn't break detection. */
|
||||
detect: HeaderMatcher[];
|
||||
mapping: Partial<Record<keyof ImportMapping, HeaderMatcher>>;
|
||||
dateFormat: DateFormat;
|
||||
amountFormat: AmountFormat;
|
||||
/** Rows whose `column` value fails `accept` are skipped before parsing —
|
||||
* e.g. a "Статус" column that reads FAILED for a declined payment. */
|
||||
rowFilter?: { column: HeaderMatcher; accept: (value: string) => boolean };
|
||||
}
|
||||
|
||||
/** Lower-cased, trimmed, BOM-stripped, whitespace-collapsed header text.
|
||||
* Also folds the Latin "i" into the Cyrillic "і": monobank's Ukrainian header
|
||||
* literally reads "Дата i час операції" with a Latin i in the middle, and
|
||||
* nobody typing a matcher by hand would guess that. */
|
||||
export function normalizeHeader(header: string): string {
|
||||
return header
|
||||
.replace(/^\uFEFF/, "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/i/g, "і");
|
||||
}
|
||||
|
||||
function findHeader(headers: string[], matcher: HeaderMatcher): string {
|
||||
const candidates = matcher.map(normalizeHeader);
|
||||
return headers.find((header) => candidates.some((candidate) => normalizeHeader(header).startsWith(candidate))) ?? "";
|
||||
}
|
||||
|
||||
export const BANK_PRESETS: readonly BankPreset[] = [
|
||||
{
|
||||
// Web version → Операции → «Выгрузка операций» → CSV. Semicolon-separated,
|
||||
// every field quoted, comma decimal mark, dates carry a time of day.
|
||||
// "Сумма платежа" is the amount in the card's own currency (a foreign
|
||||
// purchase shows the original amount in "Сумма операции"), which is what
|
||||
// the account balance actually moved by.
|
||||
id: "tbank",
|
||||
label: "Т-Банк",
|
||||
detect: [["дата операции"], ["сумма платежа"], ["описание"], ["статус"]],
|
||||
mapping: {
|
||||
date: ["дата операции"],
|
||||
amount: ["сумма платежа"],
|
||||
description: ["описание"],
|
||||
category: ["категория"],
|
||||
},
|
||||
dateFormat: "DD.MM.YYYY",
|
||||
amountFormat: "comma-decimal",
|
||||
rowFilter: { column: ["статус"], accept: (value) => value.trim().toUpperCase() === "OK" },
|
||||
},
|
||||
{
|
||||
// App → card → Виписка → CSV, Ukrainian UI language. Comma-separated,
|
||||
// UTF-8, dot decimal mark, empty cells written as an em dash ("—").
|
||||
// The amount header embeds the card currency, hence the prefix match.
|
||||
id: "monobank",
|
||||
label: "monobank",
|
||||
detect: [["дата і час операції"], ["деталі операції"], ["сума в валюті картки"]],
|
||||
mapping: {
|
||||
date: ["дата і час операції"],
|
||||
amount: ["сума в валюті картки"],
|
||||
description: ["деталі операції"],
|
||||
},
|
||||
dateFormat: "DD.MM.YYYY",
|
||||
amountFormat: "dot-decimal",
|
||||
},
|
||||
{
|
||||
// next.privat24.ua → card → Виписки → CSV. Date and time come in
|
||||
// separate columns, so the plain date parses as-is; the amount in the
|
||||
// card's currency is what the balance moved by.
|
||||
id: "privatbank",
|
||||
label: "ПриватБанк",
|
||||
detect: [["дата"], ["опис операції"], ["сума в валюті картки"]],
|
||||
mapping: {
|
||||
date: ["дата"],
|
||||
amount: ["сума в валюті картки"],
|
||||
description: ["опис операції"],
|
||||
category: ["категорія"],
|
||||
},
|
||||
dateFormat: "DD.MM.YYYY",
|
||||
amountFormat: "auto",
|
||||
},
|
||||
];
|
||||
|
||||
export function findPreset(id: string): BankPreset | null {
|
||||
return BANK_PRESETS.find((preset) => preset.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** The first preset whose every `detect` matcher finds a header. Order in
|
||||
* BANK_PRESETS matters only if two banks ever share a header set. */
|
||||
export function detectPreset(headers: string[]): BankPreset | null {
|
||||
return BANK_PRESETS.find((preset) => preset.detect.every((matcher) => findHeader(headers, matcher) !== "")) ?? null;
|
||||
}
|
||||
|
||||
/** Resolves a preset's matchers against the actual header row. Fields the
|
||||
* preset doesn't cover fall back to `fallback` (the wizard's generic
|
||||
* header guesses), so a preset only has to describe what it knows. */
|
||||
export function applyPresetMapping(preset: BankPreset, headers: string[], fallback: ImportMapping): ImportMapping {
|
||||
const resolved = { ...fallback };
|
||||
(Object.keys(preset.mapping) as (keyof ImportMapping)[]).forEach((field) => {
|
||||
const matcher = preset.mapping[field];
|
||||
if (matcher) resolved[field] = findHeader(headers, matcher);
|
||||
});
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** When `preset.rowFilter` says this row is not a real transaction, returns
|
||||
* the offending cell value (for the "skipped because…" message); null means
|
||||
* keep the row. Also null when the preset has no filter or its column is
|
||||
* missing from the header row — an absent status column is not a reason
|
||||
* to drop data. */
|
||||
export function rowFilterRejection(preset: BankPreset | null, headers: string[], cells: string[]): string | null {
|
||||
if (!preset?.rowFilter) return null;
|
||||
const idx = headers.indexOf(findHeader(headers, preset.rowFilter.column));
|
||||
if (idx < 0) return null;
|
||||
const value = cells[idx] ?? "";
|
||||
return preset.rowFilter.accept(value) ? null : value.trim();
|
||||
}
|
||||
@@ -57,12 +57,17 @@ export function parseCsv(text: string): string[][] {
|
||||
export const DATE_FORMATS = ["YYYY-MM-DD", "DD.MM.YYYY", "DD/MM/YYYY", "MM/DD/YYYY", "DD-MM-YYYY"] as const;
|
||||
export type DateFormat = (typeof DATE_FORMATS)[number];
|
||||
|
||||
// Every pattern tolerates an optional trailing time of day ("24.10.2018
|
||||
// 17:04:25", "2024-01-05T09:30") — bank exports routinely timestamp each
|
||||
// operation (T-Bank, monobank), and the wizard only stores the calendar
|
||||
// date anyway.
|
||||
const TIME_SUFFIX = /(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?$/.source;
|
||||
const DATE_PATTERNS: Record<DateFormat, { regex: RegExp; order: ["y" | "m" | "d", "y" | "m" | "d", "y" | "m" | "d"] }> = {
|
||||
"YYYY-MM-DD": { regex: /^(\d{4})-(\d{1,2})-(\d{1,2})$/, order: ["y", "m", "d"] },
|
||||
"DD.MM.YYYY": { regex: /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/, order: ["d", "m", "y"] },
|
||||
"DD/MM/YYYY": { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, order: ["d", "m", "y"] },
|
||||
"MM/DD/YYYY": { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, order: ["m", "d", "y"] },
|
||||
"DD-MM-YYYY": { regex: /^(\d{1,2})-(\d{1,2})-(\d{4})$/, order: ["d", "m", "y"] },
|
||||
"YYYY-MM-DD": { regex: new RegExp(/^(\d{4})-(\d{1,2})-(\d{1,2})/.source + TIME_SUFFIX), order: ["y", "m", "d"] },
|
||||
"DD.MM.YYYY": { regex: new RegExp(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] },
|
||||
"DD/MM/YYYY": { regex: new RegExp(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] },
|
||||
"MM/DD/YYYY": { regex: new RegExp(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/.source + TIME_SUFFIX), order: ["m", "d", "y"] },
|
||||
"DD-MM-YYYY": { regex: new RegExp(/^(\d{1,2})-(\d{1,2})-(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] },
|
||||
};
|
||||
|
||||
/** Parses `raw` per `format`, returning an ISO "YYYY-MM-DD" string, or null
|
||||
|
||||
@@ -264,6 +264,12 @@ const ru = {
|
||||
"transactions.import.duplicateCheckFailed": "Не удалось проверить, есть ли уже такие транзакции на счёте — при импорте возможны дубли.",
|
||||
"transactions.import.duplicatesFound": "Найдено похожих на уже существующие: {{count}}. По умолчанию они не импортируются.",
|
||||
"transactions.import.includeDuplicates": "Всё равно импортировать их",
|
||||
"transactions.import.presetLabel": "Банк / формат файла",
|
||||
"transactions.import.preset.auto": "Определить автоматически",
|
||||
"transactions.import.presetHint": "Готовые профили: Т-Банк, monobank, ПриватБанк — колонки и форматы подставятся сами. Выгрузки других банков импортируются через ручное сопоставление колонок.",
|
||||
"transactions.import.presetDetected": "Профиль банка: {{bank}}. Колонки и форматы подставлены автоматически — проверьте и при необходимости поправьте.",
|
||||
"transactions.import.presetNotDetected": "Формат банка не распознан — сопоставьте колонки вручную или выберите банк из списка.",
|
||||
"transactions.import.errorRowFiltered": "операция не проведена (статус «{{value}}»)",
|
||||
"transactions.allTypes": "Все типы",
|
||||
"transactions.expense": "Расходы",
|
||||
"transactions.income": "Доходы",
|
||||
@@ -783,6 +789,12 @@ const en: Record<keyof typeof ru, string> = {
|
||||
"transactions.import.duplicateCheckFailed": "Couldn't check what's already on this account — importing may create duplicates.",
|
||||
"transactions.import.duplicatesFound": "Found {{count}} that look like duplicates of existing transactions. They're left out by default.",
|
||||
"transactions.import.includeDuplicates": "Import them anyway",
|
||||
"transactions.import.presetLabel": "Bank / file format",
|
||||
"transactions.import.preset.auto": "Detect automatically",
|
||||
"transactions.import.presetHint": "Built-in profiles: T-Bank, monobank, PrivatBank — columns and formats are pre-filled. Exports from other banks go through manual column mapping.",
|
||||
"transactions.import.presetDetected": "Bank profile: {{bank}}. Columns and formats were pre-filled — review and adjust if needed.",
|
||||
"transactions.import.presetNotDetected": "Bank format not recognised — map the columns by hand or pick a bank from the list.",
|
||||
"transactions.import.errorRowFiltered": "operation was not completed (status “{{value}}”)",
|
||||
"transactions.allTypes": "All types",
|
||||
"transactions.expense": "Expenses",
|
||||
"transactions.income": "Income",
|
||||
|
||||
@@ -8,6 +8,15 @@ import { useAccounts } from "@/hooks/useAccounts";
|
||||
import { useCategories } from "@/hooks/useCategories";
|
||||
import { useBulkCreateTransactions, useTransactionsForDuplicateCheck } from "@/hooks/useTransactions";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import {
|
||||
BANK_PRESETS,
|
||||
applyPresetMapping,
|
||||
detectPreset,
|
||||
findPreset,
|
||||
rowFilterRejection,
|
||||
type BankPreset,
|
||||
type ImportMapping,
|
||||
} from "@/lib/bankPresets";
|
||||
import {
|
||||
AMOUNT_FORMATS,
|
||||
DATE_FORMATS,
|
||||
@@ -35,14 +44,12 @@ const NONE = "";
|
||||
const ENCODINGS = ["utf-8", "windows-1251", "windows-1252", "koi8-r"] as const;
|
||||
type Encoding = (typeof ENCODINGS)[number];
|
||||
|
||||
interface Mapping {
|
||||
date: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
merchant: string;
|
||||
notes: string;
|
||||
category: string;
|
||||
}
|
||||
// Header names, not indexes — see lib/bankPresets.ts's ImportMapping.
|
||||
type Mapping = ImportMapping;
|
||||
|
||||
// "auto" lets detectPreset() pick a bank profile from the header row; a
|
||||
// preset id pins one regardless of what the headers look like.
|
||||
const PRESET_AUTO = "auto";
|
||||
|
||||
interface SkippedRow {
|
||||
row: number;
|
||||
@@ -72,6 +79,26 @@ function guessMapping(headerRow: string[]): Mapping {
|
||||
};
|
||||
}
|
||||
|
||||
/** The bank profile in force for a given header row: the pinned one when
|
||||
* the user picked a bank, otherwise whatever the headers match (or null —
|
||||
* plain generic mapping). Pure, so both the file handler and the
|
||||
* header-change effect below resolve it the same way. */
|
||||
function resolvePreset(headers: string[], presetChoice: string): BankPreset | null {
|
||||
return presetChoice === PRESET_AUTO ? detectPreset(headers) : findPreset(presetChoice);
|
||||
}
|
||||
|
||||
/** Map-step defaults for a header row: the preset's answers where it has
|
||||
* them, the generic header guesses everywhere else. */
|
||||
function resolveDefaults(headers: string[], preset: BankPreset | null): { mapping: Mapping; dateFormat: DateFormat; amountFormat: AmountFormat } {
|
||||
const guessed = guessMapping(headers);
|
||||
if (!preset) return { mapping: guessed, dateFormat: "YYYY-MM-DD", amountFormat: "auto" };
|
||||
return {
|
||||
mapping: applyPresetMapping(preset, headers, guessed),
|
||||
dateFormat: preset.dateFormat,
|
||||
amountFormat: preset.amountFormat,
|
||||
};
|
||||
}
|
||||
|
||||
export function CsvImportPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -85,6 +112,7 @@ export function CsvImportPage() {
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
|
||||
const [encoding, setEncoding] = useState<Encoding>("utf-8");
|
||||
const [presetChoice, setPresetChoice] = useState<string>(PRESET_AUTO);
|
||||
const [mapping, setMapping] = useState<Mapping>({ date: "", amount: "", description: "", merchant: "", notes: "", category: "" });
|
||||
const [dateFormat, setDateFormat] = useState<DateFormat>("YYYY-MM-DD");
|
||||
const [amountFormat, setAmountFormat] = useState<AmountFormat>("auto");
|
||||
@@ -105,7 +133,10 @@ export function CsvImportPage() {
|
||||
return { headers: headerRow, dataRows: rest };
|
||||
}, [fileBuffer, encoding]);
|
||||
|
||||
// Owns mapping initialisation, keyed on the header row's *contents*.
|
||||
const activePreset = useMemo(() => resolvePreset(headers, presetChoice), [headers, presetChoice]);
|
||||
|
||||
// Owns mapping initialisation, keyed on the header row's *contents* and
|
||||
// the chosen bank profile.
|
||||
// Changing the encoding re-decodes that row, and a column picked under the
|
||||
// previous encoding then names a header that no longer exists:
|
||||
// headers.indexOf() returns -1, the dropdown renders blank while
|
||||
@@ -114,15 +145,25 @@ export function CsvImportPage() {
|
||||
// reading of a mapping whose columns are all gone. When the decoded
|
||||
// headers come out identical (an all-ASCII header row survives every
|
||||
// encoding here), the key doesn't change, this doesn't run, and manual
|
||||
// column picks stay put.
|
||||
// column picks stay put. Switching the bank profile re-runs it on
|
||||
// purpose: the whole point of picking a bank is to replace the mapping.
|
||||
// JSON.stringify, not join(): joining on a separator would read
|
||||
// ["ab","c"] and ["a","bc"] as one and the same header row.
|
||||
const headersKey = JSON.stringify(headers);
|
||||
useEffect(() => {
|
||||
if (headers.length === 0) return;
|
||||
setMapping(guessMapping(headers));
|
||||
const preset = resolvePreset(headers, presetChoice);
|
||||
const defaults = resolveDefaults(headers, preset);
|
||||
setMapping(defaults.mapping);
|
||||
// Formats are only overwritten when a bank profile dictates them; with
|
||||
// no profile, a date format the user picked by hand survives an
|
||||
// encoding switch (the fresh-file reset lives in handleFileSelected).
|
||||
if (preset) {
|
||||
setDateFormat(defaults.dateFormat);
|
||||
setAmountFormat(defaults.amountFormat);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [headersKey]);
|
||||
}, [headersKey, presetChoice]);
|
||||
|
||||
const categoryLookup = useMemo(() => {
|
||||
const map: Record<"income" | "expense", Map<string, number>> = { income: new Map(), expense: new Map() };
|
||||
@@ -206,6 +247,15 @@ export function CsvImportPage() {
|
||||
const rawNotes = notesIdx >= 0 ? (cells[notesIdx] ?? "").trim() : "";
|
||||
const rawCategory = categoryIdx >= 0 ? (cells[categoryIdx] ?? "").trim() : "";
|
||||
|
||||
// Bank-profile row filter first (e.g. T-Bank's "Статус" = FAILED):
|
||||
// a declined payment parses perfectly well as a date + amount, and
|
||||
// nothing downstream could tell it apart from a real expense.
|
||||
const rejectedStatus = rowFilterRejection(activePreset, headers, cells);
|
||||
if (rejectedStatus !== null) {
|
||||
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorRowFiltered", { value: rejectedStatus || "—" }) });
|
||||
return;
|
||||
}
|
||||
|
||||
const isoDate = parseDateWithFormat(rawDate, dateFormat);
|
||||
if (!isoDate) {
|
||||
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadDate", { value: rawDate || "—" }) });
|
||||
@@ -251,7 +301,7 @@ export function CsvImportPage() {
|
||||
duplicateCount: duplicateRows.length,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step, headers, dataRows, mapping, dateFormat, amountFormat, categoryLookup, accountId, existingKeys, includeDuplicates, t]);
|
||||
}, [step, headers, dataRows, mapping, dateFormat, amountFormat, categoryLookup, accountId, existingKeys, includeDuplicates, activePreset, t]);
|
||||
|
||||
async function handleFileSelected(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -277,11 +327,16 @@ export function CsvImportPage() {
|
||||
return;
|
||||
}
|
||||
const [headerRow] = rows;
|
||||
// Full reset for a fresh file — the header-change effect above skips
|
||||
// files whose header row is byte-identical to the previous one, so
|
||||
// formats from the last import can't be left over here.
|
||||
const defaults = resolveDefaults(headerRow, resolvePreset(headerRow, presetChoice));
|
||||
setFileName(file.name);
|
||||
setFileBuffer(buffer);
|
||||
setEncoding(detectedEncoding);
|
||||
setMapping(guessMapping(headerRow));
|
||||
setAmountFormat("auto");
|
||||
setMapping(defaults.mapping);
|
||||
setDateFormat(defaults.dateFormat);
|
||||
setAmountFormat(defaults.amountFormat);
|
||||
setIncludeDuplicates(false);
|
||||
setStep("map");
|
||||
}
|
||||
@@ -307,6 +362,23 @@ export function CsvImportPage() {
|
||||
|
||||
const mappingComplete = Boolean(mapping.date && mapping.amount && mapping.description);
|
||||
|
||||
// Same control on the upload and map steps: picking the bank up front is
|
||||
// the "I know what this file is" path, changing it on the map step is the
|
||||
// "auto-detect guessed wrong" path.
|
||||
const presetSelect = (id: string) => (
|
||||
<div>
|
||||
<Label htmlFor={id}>{t("transactions.import.presetLabel")}</Label>
|
||||
<Select id={id} value={presetChoice} onChange={(event) => setPresetChoice(event.target.value)} className="sm:w-72">
|
||||
<option value={PRESET_AUTO}>{t("transactions.import.preset.auto")}</option>
|
||||
{BANK_PRESETS.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -346,6 +418,8 @@ export function CsvImportPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-text-secondary">{t("transactions.import.uploadHint")}</p>
|
||||
{presetSelect("upload-preset")}
|
||||
<p className="text-xs text-text-muted">{t("transactions.import.presetHint")}</p>
|
||||
<Button onClick={() => fileInputRef.current?.click()} disabled={!accountId}>
|
||||
<Upload size={16} />
|
||||
{t("transactions.import.chooseFile")}
|
||||
@@ -364,6 +438,13 @@ export function CsvImportPage() {
|
||||
<span className="text-xs text-text-muted">{fileName}</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{presetSelect("map-preset")}
|
||||
<p className={`text-xs ${activePreset ? "text-success" : "text-text-muted"}`}>
|
||||
{activePreset
|
||||
? t("transactions.import.presetDetected", { bank: activePreset.label })
|
||||
: t("transactions.import.presetNotDetected")}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="map-encoding">{t("transactions.import.encodingLabel")}</Label>
|
||||
<Select id="map-encoding" value={encoding} onChange={(event) => setEncoding(event.target.value as Encoding)} className="sm:w-56">
|
||||
|
||||
Reference in New Issue
Block a user