From 233907e3d979e14d163c3ad641ed335e7e56da13 Mon Sep 17 00:00:00 2001
From: suphicakir
Date: Fri, 13 Nov 2026 08:58:04 +0300
Subject: [PATCH] =?UTF-8?q?feat(ekstre):=20banka/kart=20ekstresi=20i=C3=A7?=
=?UTF-8?q?e=20aktarma=20ekran=C4=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Ayarlar → Ekstre içe aktar: Excel (.xlsx/.xls) ve CSV, dosya tarayıcıda okunur
- Başlık satırı ve sütunlar otomatik bulunur; tek sütun / Borç-Alacak; eşleştirme profil olarak saklanır
- Gözden geçirme: tür + kategori önerisi, aynı açıklamalara otomatik yayılma, tekrar/engelli satırlar
- Öğrenilen kurallarda ilk kelime yedeği (MIGROS İSTANBUL → MIGROS KADIKÖY)
- Defterde iade etiketi; iade düzenlemede eksi kalır
- SheetJS resmi paketi frontend/vendor altında (npm 12 uzak paket indirmeyi engeller)
Co-Authored-By: Claude Opus 5.5
---
.dockerignore | 1 +
.gitignore | 3 +
CLAUDE.md | 1 +
Dockerfile | 2 +
backend/src/services/bank_import.rs | 76 ++-
docs/DATA_MODEL.md | 2 +-
docs/MEMORY.md | 3 +-
docs/PHASES.md | 2 +-
frontend/package-lock.json | 13 +
frontend/package.json | 1 +
frontend/src/components/layout/nav-items.ts | 2 +
frontend/src/features/ledger/entry-form.tsx | 11 +-
frontend/src/features/ledger/entry-list.tsx | 5 +-
.../src/features/settings/data-section.tsx | 11 +
frontend/src/features/statement/page.tsx | 437 ++++++++++++++++++
frontend/src/features/statement/review.tsx | 335 ++++++++++++++
frontend/src/lib/bank-import.ts | 29 ++
frontend/src/lib/read-sheet.test.ts | 47 ++
frontend/src/lib/read-sheet.ts | 66 +++
frontend/src/lib/statement.test.ts | 123 +++++
frontend/src/lib/statement.ts | 251 ++++++++++
frontend/src/lib/strings.ts | 78 ++++
frontend/src/router.tsx | 2 +
frontend/vendor/xlsx-0.20.3.tgz | Bin 0 -> 2409319 bytes
24 files changed, 1493 insertions(+), 8 deletions(-)
create mode 100644 frontend/src/features/statement/page.tsx
create mode 100644 frontend/src/features/statement/review.tsx
create mode 100644 frontend/src/lib/bank-import.ts
create mode 100644 frontend/src/lib/read-sheet.test.ts
create mode 100644 frontend/src/lib/read-sheet.ts
create mode 100644 frontend/src/lib/statement.test.ts
create mode 100644 frontend/src/lib/statement.ts
create mode 100644 frontend/vendor/xlsx-0.20.3.tgz
diff --git a/.dockerignore b/.dockerignore
index b987e71..51f3756 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,3 +9,4 @@
backups/
.vscode/
*.log
+samples/
diff --git a/.gitignore b/.gitignore
index b2086af..49035c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,6 @@ dist/
# Yedekler
backups/
+
+# Örnek ekstreler (kişisel veri; depoya girmez)
+samples/
diff --git a/CLAUDE.md b/CLAUDE.md
index f935816..2dd2808 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -79,6 +79,7 @@ npm run db:restore -- [--force]
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_` DB, `tests/common` yardımcıları).
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
+- Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir.
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
diff --git a/Dockerfile b/Dockerfile
index c6f1e99..e0ec5f7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,6 +6,8 @@ FROM node:26-alpine AS web
WORKDIR /app/frontend
# Önce yalnızca paket dosyaları → bağımlılık katmanı önbellekte kalır
COPY frontend/package.json frontend/package-lock.json ./
+# Yerel paketler (SheetJS resmi dağıtımı; npm 12 uzak adres indirmeyi engeller)
+COPY frontend/vendor/ ./vendor/
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
diff --git a/backend/src/services/bank_import.rs b/backend/src/services/bank_import.rs
index c797b16..8c8ad36 100644
--- a/backend/src/services/bank_import.rs
+++ b/backend/src/services/bank_import.rs
@@ -129,6 +129,47 @@ pub fn rule_key(desc: &str) -> String {
.join(" ")
}
+/// İlk kelime eşleşmesinde kullanılmayacak genel sözcükler (satıcı adı değil)
+const GENERIC: &[&str] = &[
+ "iade",
+ "odeme",
+ "pos",
+ "isyeri",
+ "eft",
+ "havale",
+ "fast",
+ "virman",
+ "kredi",
+ "kart",
+ "hesap",
+ "para",
+ "borc",
+ "harcama",
+ "alisveris",
+ "nakit",
+ "atm",
+ "taksit",
+ "ucret",
+];
+
+/// Öneri: tam kural (ilk 2 kelime); yoksa ilk kelimesi aynı kurallar hepsi aynı şeyi
+/// söylüyorsa o (ör. "migros istanbul" öğrenildi → "MIGROS KADIKOY" de Market)
+fn suggest<'a>(rules: &'a HashMap, desc: &str) -> Option<&'a Rule> {
+ if let Some(r) = rules.get(&rule_key(desc)) {
+ return Some(r);
+ }
+ let first = words(desc).into_iter().next()?;
+ if first.len() < 3 || GENERIC.contains(&first.as_str()) {
+ return None;
+ }
+ let mut same = rules
+ .values()
+ .filter(|r| r.pattern.split(' ').next() == Some(first.as_str()));
+ let r = same.next()?;
+ same.all(|x| x.kind == r.kind && x.category_id == r.category_id)
+ .then_some(r)
+}
+
/// Tekrar anahtarı (tüm açıklama, sadeleştirilmiş)
pub fn import_key(date: &str, dir: Direction, amount: i64, desc: &str) -> String {
let d = match dir {
@@ -359,7 +400,7 @@ pub async fn preview(
&mut keys,
&import_key(&r.date, r.direction, r.amount, &r.description),
);
- let rule = rules.get(&rule_key(&r.description));
+ let rule = suggest(&rules, &r.description);
PreviewRow {
kind: rule.map_or_else(
|| default_kind(source, r.direction, &r.description),
@@ -586,6 +627,39 @@ mod tests {
);
}
+ #[test]
+ fn suggestion_falls_back_to_first_word() {
+ let market = Uuid::new();
+ let ulasim = Uuid::new();
+ let rule = |p: &str, c: Uuid| {
+ (
+ p.to_string(),
+ Rule {
+ pattern: p.into(),
+ kind: Kind::Expense,
+ category_id: Some(c),
+ },
+ )
+ };
+ let mut rules: HashMap =
+ [rule("migros istanbul", market), rule("iade migros", market)].into();
+ assert_eq!(
+ suggest(&rules, "MIGROS 99 ISTANBUL").unwrap().category_id,
+ Some(market),
+ "tam"
+ );
+ assert_eq!(
+ suggest(&rules, "MİGROS KADIKÖY").unwrap().category_id,
+ Some(market),
+ "ilk kelime"
+ );
+ assert!(suggest(&rules, "IADE SHELL").is_none(), "genel ilk kelime");
+ assert!(suggest(&rules, "A101 SUBE").is_none());
+ // İlk kelimesi aynı ama farklı kategoriler → öneri yok
+ rules.extend([rule("migros jet", ulasim)]);
+ assert!(suggest(&rules, "MIGROS ANKARA").is_none());
+ }
+
#[test]
fn default_kinds() {
use Direction::*;
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index 5ec334c..256113e 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -146,7 +146,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
{ _id, household_id, pattern /* açıklamanın ilk 2 anlamlı kelimesi, ASCII küçük: "migros istanbul" */,
kind: "expense" | "refund" | "income" | "ignore", category_id, uses, created_at, updated_at, version }
```
-- Aktarmada her seçim kural olarak yazılır (upsert); sonraki ekstrede aynı `pattern` öneri olur.
+- Aktarmada her seçim kural olarak yazılır (upsert); sonraki ekstrede aynı `pattern` öneri olur. Tam eşleşme yoksa **ilk kelimesi aynı** kurallar hepsi aynı tür+kategoriyi söylüyorsa o önerilir ("migros istanbul" → "MIGROS KADIKOY"); genel ilk kelimeler (iade, ödeme, pos, eft…) hariç.
### 3.10 `carryovers` — Devirler
```js
diff --git a/docs/MEMORY.md b/docs/MEMORY.md
index 2fd7b3f..7b81fb0 100644
--- a/docs/MEMORY.md
+++ b/docs/MEMORY.md
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
-**Faz 9 – Adım 4b:** Ekstre arayüzü (Ayarlar → Veri ya da ayrı sayfa `/ice-aktar`). Excel okuma: SheetJS (`xlsx` npm sürümü eski/açıklı → `https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz` tarball; lazy; gerekçe ver) — .xlsx/.xls (+ HTML-xls), CSV `lib/decode.ts`. Akış: dosya → sayfa/başlık satırı seç → sütun eşleştir (tarih, açıklama, tutar ya da Borç/Alacak iki sütun, işaret: kart/hesap) + **profil** kaydet (localStorage, banka adıyla) → `POST /bank-import/preview` → tablo: satır başına tür (harcama/iade/gelir/yoksay) + kategori seçimi (öneri, `from_rule` rozeti), tekrar/engelli satırlar soluk, açılacak arşivler listesi, toplu "aynı açıklamaya uygula" → ödeme türü seç → `commit` → rapor. Tarih biçimleri (GG.AA.YYYY, GG/AA/YYYY, Excel seri sayı), tutar (1.250,50 / -45,90 / Borç-Alacak). Defter: iade satırı "İade" rozeti; iade düzenlemede tutar alanı eksi/mutlak (şu an istemci şeması eksiyi reddeder → düzelt).
+**Faz 9 – Adım 5:** JSON tam hane yedeği. Önce best practice analizi + tasarımı kullanıcıya sun (kullanıcı "üzmeyen, işi kolaylaştıran" istedi): sürümlü biçim (uygulama sürümü + şema/migration sürümü + dışa aktarma zamanı + hane adı), içerik (dönemler, plan, bütçe, harcamalar, devirler, notlar, kategoriler, ödeme türleri, varlıklar, işlemler, manuel kurlar, ekstre kuralları; kullanıcı/şifre/davet **yok**), geri yükleme yalnız sahip, önizleme (sayılar, tarih aralığı, kaynak hane), **sunucuda otomatik geri dönüş noktası** + "geri yüklemeyi geri al" (süre? örn. 7 gün / son 3), başka haneden taşıma (kimlikler yeniden üretilir mi korunur mu — iç referanslar!), transaction, büyük dosya sınırı. Sonra Adım 6: aylık rapor (Claude tasarlar).
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
## 📌 Alınan Kararlar
@@ -139,3 +139,4 @@
- **Adım 2:** Raporlar sayfası (`features/reports/`): `page.tsx` (filtre adreste: `donem`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme` (`yok` = girilmemiş), dönem varsa tarih yok sayılır), `filters.tsx` (Kapsam: tüm zamanlar / dönem / tarih aralığı (yıl başı → bugün varsayılan), grup seçilince kategori listesi daralır, mobilde katlanır), `section.tsx` (`ReportSection`: `data-report`, CSV indir + bölüm yazdır; `ShareTable`: satır içi CSS pay çubukları — yazdırmada da görünür), `sections.tsx` (gruplar, ödeme türleri, **tüm** kategoriler, aylar = filtreli; bütçe sapması = seçili/açık/son dönem, grup + hafta; gelir–gider + birikim oranı = son 12 dönem; varlıklar), `charts.tsx` lazy (bar + çizgi). `lib/csv.ts` (BOM, `;`, CRLF, tırnaklama, `csvMoney` binliksiz), `lib/reports.ts` (`parseFilter`/`filterToParams`/`filterToQuery`, `describeFilter` → yazdırmada kapsam satırı, `savingsRate`, `deviation`, `formatMonth`), `lib/print.ts` (`installPrintTheme`: beforeprint'te koyu tema kaldırılır; `printSection`: `data-print-one` + `data-print-target`), index.css `@media print`, layout `print:hidden`. Kayıt değişince `['reports']` yenilenir. Frontend 121 test; e2e 34 kontrol (CSV baytları, yazdırma hedefi, tema, filtreler). Not: `Blob.text()` BOM'u siler → BOM'u baytlardan doğrula.
- **Adım 3:** CSV dışa/içe aktarma. Backend `services/csv_io.rs` (saf: yazma BOM/`;`/CRLF/`1250,50`/GG.AA.YYYY; okuma: BOM, `;`/`,`/sekme otomatik, tırnak, satır numarası, `norm` başlık eşleme (Türkçe İ/ı), `parse_money` (1.250,50 · 1250.50 · 1.000=bin), `parse_e6`, `parse_date_any`), `services/transfer.rs` (harcama/plan/varlık: dışa aktar; içe aktar `commit=false` deneme → rapor {total, valid, duplicates, imported, errors[(satır, neden)], preview}; tekrar sayacı `Existing` (dosyada 2, DB'de 1 → 1 eklenir); harcamada dönem tarihten, kapalı/yok → satır hatası, kategori adla (+grup), aynı ad 2 grupta → hata; plan `plan::create` ile; varlıkta tarih+dosya sırasına göre eldekinden fazla satış simülasyonu, yazma `asset::create_tx`). `Group::label()`. Rotalar `routes/transfer.rs` (`/export/*.csv`, `POST /import/{tür}` 5 MB). `tests/transfer.rs` (3) + csv_io birim (4); mutasyon 3/3. Frontend: Ayarlar → Veri (`features/settings/data-section.tsx`: indirme bağlantıları, içe aktarma: tür seç → dosya → deneme raporu → onay), `api.postCsv`, `lib/decode.ts` (UTF-8 fatal → Windows-1254), `lib/transfer.ts`. Backend 164, frontend 125 test; e2e 16 kontrol.
- **Adım 4a:** Ekstre backend. `PeriodStatus::Archived` (yazılabilir; kapatılmaz 409; devir zincirinde atlanır: `period::create` önceki, `closing::next_period`, reopen engeli; `budget::previous_closed` kapalı+arşiv), `period::create_archive`. `services/bank_import.rs`: `fold` (Türkçe → ASCII; **"İ".to_lowercase() = i + U+0307** düzeltildi), `rule_key` (ilk 2 anlamlı kelime), `import_key`, `default_kind` (ödeme/virman → yoksay; kartta giriş → iade, hesapta → gelir), `target` (içeren yazılabilir dönem / takvim ayı arşivi (kırpılmış) / gelecek-kapalı engel), `preview`, `commit` (arşivleri açar, harcama/iade insert_many + `import_key`, gelir `plan::create` + `import_key`, kurallar upsert `import_rules`). Entry update: iade eksi kalır. Rotalar `routes/bank_import.rs` (`/bank-import/preview|commit`, 5 MB). `tests/bank_import.rs` (3, devir zinciri dahil); mutasyon 5/5. Frontend: `archived` durum metni, Değerlendirme'de arşiv bilgisi, kapatma sihirbazı arşivi reddeder. Backend 177 test.
+- **Adım 4b:** Ekstre arayüzü `/ayarlar/ekstre` (`features/statement/`: `page.tsx` Dosya → Eşleştir (başlık satırı otomatik `detectHeaderRow`, sütun tahmini `guessMapping`, kaynak kart/hesap → tutar modu, Borç/Alacak, ödeme türü, profil adı → localStorage `kakeibo.statement.profiles`, ad ile eşleşir; önizlemede tarih yorumlanmış) ; `review.tsx` (tür+kategori, değişiklik aynı `ruleKey`'li elle değiştirilmemiş satırlara yayılır + bildirim, filtreler, sayfalama 50, telefonda kart/masaüstünde sütunlu liste, atlanacaklar soluk, arşiv bildirimi, kategori eksikse aktarma kapalı, sonuçta hata satırları tarih+açıklama ile)). `lib/statement.ts` (Excel seri tarih, `parseAmount` TR/EN/parantez/sondaki eksi, `normalize`, `fold`/`ruleKey` = backend, profiller), `lib/read-sheet.ts` (SheetJS lazy 160 KB gz, `blankrows: true` → satır no Excel ile aynı; CSV kendi ayrıştırıcı + `decodeText`), `lib/bank-import.ts`. Backend: kural önerisine **ilk kelime yedeği** (`suggest`, genel kelimeler hariç). SheetJS yerel paket (`frontend/vendor`, Dockerfile kopyalar; Docker web aşaması doğrulandı). Defter: iade satırı "İade" etiketi, düzenlemede artı gösterilip eksi kaydedilir. Frontend 134, backend 178 test; e2e 25 kontrol.
diff --git a/docs/PHASES.md b/docs/PHASES.md
index a319364..0116c40 100644
--- a/docs/PHASES.md
+++ b/docs/PHASES.md
@@ -82,6 +82,6 @@
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
- ✅ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
-- 🔄 4. **Banka / kredi kartı ekstresi içe aktarma** — ✅ 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) · ⏳ 4b arayüz (Excel/CSV okuma, sütun eşleştirme + banka profili, satır gözden geçirme)
+- ✅ 4. **Banka / kredi kartı ekstresi içe aktarma** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme)
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 205260e..a0f4d1a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -27,6 +27,7 @@
"sonner": "^2.0.8",
"tailwindcss": "^4.3.5",
"tw-animate-css": "^1.4.0",
+ "xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "^4.6.7"
},
"devDependencies": {
@@ -9024,6 +9025,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/xlsx": {
+ "version": "0.20.3",
+ "resolved": "file:vendor/xlsx-0.20.3.tgz",
+ "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "xlsx": "bin/xlsx.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index d66cee4..204d3de 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -33,6 +33,7 @@
"sonner": "^2.0.8",
"tailwindcss": "^4.3.5",
"tw-animate-css": "^1.4.0",
+ "xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "^4.6.7"
},
"devDependencies": {
diff --git a/frontend/src/components/layout/nav-items.ts b/frontend/src/components/layout/nav-items.ts
index 4dceb09..4c66c2f 100644
--- a/frontend/src/components/layout/nav-items.ts
+++ b/frontend/src/components/layout/nav-items.ts
@@ -29,6 +29,8 @@ export const paths = {
closePeriod: '/degerlendirme/kapat',
reports: '/raporlar',
settings: '/ayarlar',
+ /** Banka / kart ekstresi içe aktarma */
+ statement: '/ayarlar/ekstre',
login: '/giris',
register: '/kayit',
} as const
diff --git a/frontend/src/features/ledger/entry-form.tsx b/frontend/src/features/ledger/entry-form.tsx
index 46df79e..4354f74 100644
--- a/frontend/src/features/ledger/entry-form.tsx
+++ b/frontend/src/features/ledger/entry-form.tsx
@@ -32,7 +32,8 @@ function initialValues(
): EntryForm {
if (entry) {
return {
- amount: moneyToText(entry.amount),
+ // İade (ekstreden, eksi tutar) formda artı gösterilir, eksi kaydedilir
+ amount: moneyToText(Math.abs(entry.amount)),
group: entry.group,
category_id: entry.category_id,
payment_method_id: entry.payment_method_id ?? '',
@@ -143,7 +144,7 @@ export function EntryForm({
id: entry.id,
version: entry.version,
date: v.date,
- amount: toKurus(v.amount),
+ amount: entry.amount < 0 ? -toKurus(v.amount) : toKurus(v.amount),
category_id: v.category_id,
payment_method_id: v.payment_method_id, // '' → kaldır
description: v.description.trim(), // '' → kaldır
@@ -182,7 +183,11 @@ export function EntryForm({
control={control}
name="amount"
render={({ field }) => (
-
+
{(f) => (
` · ${d}`)}
- {formatMoney(e.amount)}
+
+ {e.amount < 0 && {s.refund}}
+ {formatMoney(e.amount)}
+
{!readOnly && (
)
diff --git a/frontend/src/features/statement/page.tsx b/frontend/src/features/statement/page.tsx
new file mode 100644
index 0000000..979de76
--- /dev/null
+++ b/frontend/src/features/statement/page.tsx
@@ -0,0 +1,437 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { ArrowLeft, FileUp, Loader2 } from 'lucide-react'
+import { Link } from 'react-router'
+import { toast } from 'sonner'
+import { FormError } from '@/components/form-field'
+import { paths } from '@/components/layout/nav-items'
+import { PageHeader } from '@/components/page'
+import { Segmented } from '@/components/segmented'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Stepper } from '@/features/period-wizard/wizard-ui'
+import { Section } from '@/features/planning/plan-read'
+import { useBankPreview } from '@/lib/bank-import'
+import { usePaymentMethods } from '@/lib/catalog'
+import { formatDate } from '@/lib/format'
+import { readSheets, type Sheet } from '@/lib/read-sheet'
+import {
+ applyProfile,
+ detectHeaderRow,
+ guessMapping,
+ loadProfiles,
+ normalize,
+ parseCellDate,
+ saveProfile,
+ type AmountMode,
+ type Cell,
+ type Mapping,
+ type Source,
+ type StatementRow,
+} from '@/lib/statement'
+import { t } from '@/lib/strings'
+import type { BankPreview } from '@/types/BankPreview'
+import { Review } from './review'
+
+const s = t.statement
+const select = 'h-11 w-full rounded-lg border bg-background px-3 text-sm'
+const text = (c: Cell) => (c === null || c === undefined ? '' : String(c).trim())
+
+export type Loaded = {
+ rows: StatementRow[]
+ skipped: number
+ source: Source
+ paymentMethodId: string
+ preview: BankPreview
+}
+
+/**
+ * `/ayarlar/ekstre`: Dosya → Eşleştir → Gözden geçir → aktar.
+ * Dosya tarayıcıda okunur; sunucuya yalnızca tarih/açıklama/tutar/yön gider.
+ */
+export function StatementPage() {
+ const [sheets, setSheets] = useState<{ file: string; list: Sheet[] } | null>(null)
+ const [loaded, setLoaded] = useState(null)
+ const step = loaded ? 3 : sheets ? 2 : 1
+
+ return (
+
+
+
+ {s.back}
+
+
+
+ {step === 1 &&
setSheets({ file, list })} />}
+ {step === 2 && sheets && (
+ setSheets(null)}
+ onLoaded={setLoaded}
+ />
+ )}
+ {step === 3 && loaded && (
+ setLoaded(null)}
+ onRestart={() => {
+ setLoaded(null)
+ setSheets(null)
+ }}
+ />
+ )}
+
+ )
+}
+
+function FileStep({ onRead }: { onRead: (file: string, sheets: Sheet[]) => void }) {
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState()
+ const input = useRef(null)
+
+ async function read(f: File) {
+ setBusy(true)
+ setError(undefined)
+ try {
+ const list = (await readSheets(f)).filter((x) => x.rows.length > 1)
+ if (list.length === 0) throw new Error('empty')
+ onRead(f.name, list)
+ } catch {
+ setError(s.readError)
+ if (input.current) input.current.value = ''
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+function MapStep({
+ file,
+ sheets,
+ onBack,
+ onLoaded,
+}: {
+ file: string
+ sheets: Sheet[]
+ onBack: () => void
+ onLoaded: (l: Loaded) => void
+}) {
+ const methods = usePaymentMethods()
+ const preview = useBankPreview()
+ const [profiles] = useState(loadProfiles)
+ const [sheetIdx, setSheetIdx] = useState(0)
+ const rows = sheets[sheetIdx].rows
+ const [headerRow, setHeaderRow] = useState(() => detectHeaderRow(rows))
+ const header = rows[headerRow] ?? []
+
+ // Başlangıç: başlığa uyan kayıtlı profil, yoksa adlardan tahmin
+ const [initial] = useState(() => {
+ for (const p of profiles) {
+ const m = applyProfile(p, header)
+ if (m) return { m, p }
+ }
+ return null
+ })
+ const guess = guessMapping(header)
+ const [source, setSource] = useState(initial?.p.source ?? 'card')
+ const [m, setM] = useState(
+ initial?.m ?? {
+ date: guess.date ?? -1,
+ description: guess.description ?? -1,
+ amount: guess.amount ?? -1,
+ credit: guess.credit ?? -1,
+ mode: guess.mode ?? 'signed-out-positive',
+ },
+ )
+ const [pm, setPm] = useState(initial?.p.paymentMethodId ?? '')
+ const [profileName, setProfileName] = useState(initial?.p.name ?? '')
+ const [error, setError] = useState()
+ useEffect(() => {
+ if (initial) toast.success(s.profileApplied(initial.p.name))
+ }, [initial])
+
+ const cols = header.map((c, i) => ({ i, label: text(c) || `${i + 1}. sütun` }))
+ const ready =
+ m.date >= 0 &&
+ m.description >= 0 &&
+ m.amount >= 0 &&
+ (m.mode !== 'debit-credit' || m.credit >= 0)
+
+ async function next() {
+ setError(undefined)
+ const n = normalize(rows, headerRow, m)
+ if (n.rows.length === 0) return setError(s.noRows)
+ if (profileName.trim()) {
+ saveProfile({
+ name: profileName.trim(),
+ source,
+ mode: m.mode,
+ paymentMethodId: pm || undefined,
+ columns: {
+ date: text(header[m.date]),
+ description: text(header[m.description]),
+ amount: text(header[m.amount]),
+ credit: m.mode === 'debit-credit' ? text(header[m.credit]) : undefined,
+ },
+ })
+ }
+ try {
+ const p = await preview.mutateAsync({ source, rows: n.rows })
+ onLoaded({ rows: n.rows, skipped: n.skipped.length, source, paymentMethodId: pm, preview: p })
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e))
+ }
+ }
+
+ const colSelect = (label: string, value: number, onChange: (v: number) => void, id: string) => (
+
+
+
+ )
+
+ return (
+
+
+
+
+ {sheets.length > 1 && (
+
+
+
+ )}
+
+ {profiles.length > 0 && (
+
+
+
+ )}
+
+
+
+
{s.source}
+
{
+ setSource(v)
+ if (m.mode !== 'debit-credit') {
+ setM({ ...m, mode: v === 'card' ? 'signed-out-positive' : 'signed-out-negative' })
+ }
+ }}
+ options={[
+ { value: 'card', label: s.card },
+ { value: 'account', label: s.account },
+ ]}
+ />
+
+
+
+ {colSelect(s.dateCol, m.date, (v) => setM({ ...m, date: v }), 'col-date')}
+ {colSelect(s.descCol, m.description, (v) => setM({ ...m, description: v }), 'col-desc')}
+
+
+
+ {colSelect(
+ m.mode === 'debit-credit' ? s.debitCol : s.amountCol,
+ m.amount,
+ (v) => setM({ ...m, amount: v }),
+ 'col-amount',
+ )}
+ {m.mode === 'debit-credit' &&
+ colSelect(s.creditCol, m.credit, (v) => setM({ ...m, credit: v }), 'col-credit')}
+
+
+
+
+ setProfileName(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+ {cols.map((c) => (
+ |
+ {c.label}
+ {c.i === m.date && ' 📅'}
+ {c.i === m.description && ' 📝'}
+ {(c.i === m.amount || (m.mode === 'debit-credit' && c.i === m.credit)) && ' 💰'}
+ |
+ ))}
+
+
+
+ {rows.slice(headerRow + 1, headerRow + 7).map((r, i) => (
+
+ {cols.map((c) => (
+ |
+ {/* Tarih sütunu yorumlanmış haliyle (Excel seri numarası → 10.09.2026) */}
+ {c.i === m.date && parseCellDate(r[c.i])
+ ? formatDate(parseCellDate(r[c.i]) ?? '')
+ : text(r[c.i])}
+ |
+ ))}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {t.wizard.back}
+
+
void next()}
+ >
+ {preview.isPending && }
+ {preview.isPending ? s.checking : s.continue}
+
+
+
+ )
+}
+
+export function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/features/statement/review.tsx b/frontend/src/features/statement/review.tsx
new file mode 100644
index 0000000..8b39888
--- /dev/null
+++ b/frontend/src/features/statement/review.tsx
@@ -0,0 +1,335 @@
+import { useMemo, useState } from 'react'
+import { ArrowLeft, CircleCheck, Info, Loader2, Sparkles } from 'lucide-react'
+import { Link } from 'react-router'
+import { toast } from 'sonner'
+import { FormError } from '@/components/form-field'
+import { paths } from '@/components/layout/nav-items'
+import { Segmented } from '@/components/segmented'
+import { Button } from '@/components/ui/button'
+import { Section } from '@/features/planning/plan-read'
+import { useBankCommit } from '@/lib/bank-import'
+import { useCategories } from '@/lib/catalog'
+import { formatDate, formatMoney } from '@/lib/format'
+import { GROUPS } from '@/lib/groups'
+import { ruleKey } from '@/lib/statement'
+import { t } from '@/lib/strings'
+import { cn } from '@/lib/utils'
+import type { BankImportReport } from '@/types/BankImportReport'
+import type { Loaded } from './page'
+
+const s = t.statement
+const PAGE = 50
+type Kind = 'expense' | 'refund' | 'income' | 'ignore'
+type Decision = { kind: Kind; category: string; touched: boolean }
+type Filter = 'all' | 'missing' | 'ignore' | 'skip'
+const needsCategory = (k: Kind) => k === 'expense' || k === 'refund'
+
+/** 3. adım: satır satır tür + kategori; aynı açıklamalara otomatik yayılır */
+export function Review({
+ data,
+ onBack,
+ onRestart,
+}: {
+ data: Loaded
+ onBack: () => void
+ onRestart: () => void
+}) {
+ const cats = useCategories()
+ const commit = useBankCommit()
+ const { rows, preview } = data
+ const keys = useMemo(() => rows.map((r) => ruleKey(r.description)), [rows])
+ const [dec, setDec] = useState(() =>
+ preview.rows.map((p) => ({ kind: p.kind, category: p.category_id ?? '', touched: false })),
+ )
+ const [filter, setFilter] = useState('all')
+ const [page, setPage] = useState(0)
+ const [error, setError] = useState()
+ const [report, setReport] = useState(null)
+
+ /** Atlanacak: daha önce aktarılmış ya da aktarılamaz (kapalı dönem, gelecek, hatalı) */
+ const skip = (i: number) => preview.rows[i].duplicate || preview.rows[i].period === 'blocked'
+ const active = rows.map((_, i) => i).filter((i) => !skip(i))
+ const missing = active.filter((i) => needsCategory(dec[i].kind) && !dec[i].category)
+ const duplicates = preview.rows.filter((r) => r.duplicate).length
+ const blocked = preview.rows.filter((r) => r.period === 'blocked' && !r.duplicate).length
+ const toSend = active.filter((i) => dec[i].kind !== 'ignore').length
+
+ const shown = rows
+ .map((_, i) => i)
+ .filter((i) =>
+ filter === 'missing'
+ ? missing.includes(i)
+ : filter === 'ignore'
+ ? !skip(i) && dec[i].kind === 'ignore'
+ : filter === 'skip'
+ ? skip(i)
+ : true,
+ )
+ const pageRows = shown.slice(page * PAGE, page * PAGE + PAGE)
+
+ /** Değişikliği bu satıra ve aynı açıklama anahtarlı, elle değiştirilmemiş satırlara uygula */
+ function change(i: number, patch: Partial) {
+ let similar = 0
+ const next = dec.map((d, j) => {
+ if (j === i) return { ...d, ...patch, touched: true }
+ if (keys[i] && keys[j] === keys[i] && !d.touched && !skip(j)) {
+ similar++
+ return { ...d, ...patch }
+ }
+ return d
+ })
+ setDec(next)
+ if (similar > 0) toast(s.appliedSimilar(similar))
+ }
+
+ async function run() {
+ setError(undefined)
+ try {
+ const r = await commit.mutateAsync({
+ payment_method_id: data.paymentMethodId || undefined,
+ rows: active.map((i) => ({
+ ...rows[i],
+ kind: dec[i].kind,
+ category_id: needsCategory(dec[i].kind) ? dec[i].category : undefined,
+ })),
+ })
+ setReport(r)
+ window.scrollTo({ top: 0 })
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e))
+ }
+ }
+
+ if (report) {
+ // Hata sırası gönderilen satırlara göre → kullanıcıya tarih + açıklama
+ const label = (idx: number) => {
+ const r = rows[active[idx]]
+ return r ? `${formatDate(r.date)} · ${r.description}` : `#${idx + 1}`
+ }
+ return
+ }
+
+ return (
+
+
+
+
{s.summary(rows.length, duplicates, blocked)}
+ {data.skipped > 0 && (
+
{s.skipped(data.skipped)}
+ )}
+ {preview.archives.length > 0 && (
+
+
+
+ {s.archivesTitle}: {preview.archives.map((a) => a.name).join(', ')}
+
+
{s.archivesHint}
+
+ )}
+
{
+ setFilter(f)
+ setPage(0)
+ }}
+ options={[
+ { value: 'all', label: `${s.filters.all} (${rows.length})` },
+ { value: 'missing', label: `${s.filters.missing} (${missing.length})` },
+ { value: 'ignore', label: s.filters.ignore },
+ { value: 'skip', label: `${s.filters.skip} (${duplicates + blocked})` },
+ ]}
+ />
+
+
+
+ {/* Liste: telefonda kart, geniş ekranda sütunlu satır */}
+
+
+ {t.ledger.date}
+ {t.ledger.description}
+ {t.ledger.amount}
+ {s.steps[2]}
+
+
+ {pageRows.map((i) => {
+ const r = rows[i]
+ const p = preview.rows[i]
+ const d = dec[i]
+ const off = skip(i)
+ return (
+ -
+
+ {formatDate(r.date)}
+
+
+ {r.direction === 'in' ? '+' : ''}
+ {formatMoney(r.amount)}
+
+
+
{r.description || '—'}
+
+ {p.period_name && {p.period_name}}
+ {p.from_rule && !d.touched && (
+
+
+ {s.learned}
+
+ )}
+ {p.duplicate && {s.duplicate}}
+ {!p.duplicate && p.message && (
+ {p.message}
+ )}
+
+
+ {!off && (
+
+
+ {needsCategory(d.kind) && (
+
+ )}
+
+ )}
+
+ )
+ })}
+
+
+
+ {shown.length > PAGE && (
+
+ setPage(page - 1)}>
+ {s.prev}
+
+
+ {s.page(page * PAGE + 1, Math.min(shown.length, page * PAGE + PAGE), shown.length)}
+
+ = shown.length}
+ onClick={() => setPage(page + 1)}
+ >
+ {s.next}
+
+
+ )}
+
+ {missing.length > 0 &&
{s.missing(missing.length)}
}
+
+
+
+
+ {t.wizard.back}
+
+
0 || active.length === 0 || commit.isPending}
+ onClick={() => void run()}
+ >
+ {commit.isPending && }
+ {commit.isPending ? s.committing : s.commit(toSend)}
+
+
+
+ )
+}
+
+function Result({
+ report: r,
+ label,
+ onRestart,
+}: {
+ report: BankImportReport
+ label: (index: number) => string
+ onRestart: () => void
+}) {
+ const parts = [
+ s.result.entries(r.entries),
+ s.result.refunds(r.refunds),
+ s.result.incomes(r.incomes),
+ s.result.ignored(r.ignored),
+ ...(r.duplicates ? [s.result.duplicates(r.duplicates)] : []),
+ ]
+ return (
+
+
+
+
+ {parts.join(' · ')}
+
+ {r.archives.length > 0 && (
+
+ {s.result.archives(r.archives.join(', '))}
+
+ )}
+ {r.errors.length > 0 && (
+
+
{s.errorsTitle}
+
+ {r.errors.map((e) => (
+ -
+ {label(e.index)}: {e.message}
+
+ ))}
+
+
+ )}
+
+
+ {s.toReports}
+
+
+ {s.another}
+
+
+
+
+ )
+}
diff --git a/frontend/src/lib/bank-import.ts b/frontend/src/lib/bank-import.ts
new file mode 100644
index 0000000..884571b
--- /dev/null
+++ b/frontend/src/lib/bank-import.ts
@@ -0,0 +1,29 @@
+/**
+ * Ekstre içe aktarma: önizleme (öneri + tekrar + dönem) ve aktarma (`/bank-import/*`).
+ */
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import type { BankCommitRequest } from '@/types/BankCommitRequest'
+import type { BankImportReport } from '@/types/BankImportReport'
+import type { BankPreview } from '@/types/BankPreview'
+import type { BankPreviewRequest } from '@/types/BankPreviewRequest'
+import { api } from './api'
+import { queryKeys } from './query'
+
+export const useBankPreview = () =>
+ useMutation({
+ mutationFn: (body: BankPreviewRequest) => api.post('/bank-import/preview', body),
+ })
+
+export function useBankCommit() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (body: BankCommitRequest) =>
+ api.post('/bank-import/commit', body),
+ onSuccess: () =>
+ Promise.all([
+ qc.invalidateQueries({ queryKey: queryKeys.periods }),
+ qc.invalidateQueries({ queryKey: ['period'] }),
+ qc.invalidateQueries({ queryKey: queryKeys.reports }),
+ ]),
+ })
+}
diff --git a/frontend/src/lib/read-sheet.test.ts b/frontend/src/lib/read-sheet.test.ts
new file mode 100644
index 0000000..48e7b05
--- /dev/null
+++ b/frontend/src/lib/read-sheet.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import * as XLSX from 'xlsx'
+import { parseCsvText, readSheets } from './read-sheet'
+import { normalize, parseCellDate } from './statement'
+
+describe('read-sheet', () => {
+ it('CSV with quotes and Turkish numbers stays text', () => {
+ expect(parseCsvText('Tarih;Açıklama;Tutar\r\n13.11.2026;"Kafe; tatlı";-1.250,50\r\n')).toEqual([
+ ['Tarih', 'Açıklama', 'Tutar'],
+ ['13.11.2026', 'Kafe; tatlı', '-1.250,50'],
+ ])
+ })
+
+ it('reads a real .xlsx (dates and amounts as numbers)', async () => {
+ const ws = XLSX.utils.aoa_to_sheet([
+ ['Banka', null, null],
+ ['İşlem Tarihi', 'Açıklama', 'Tutar'],
+ [46339, 'MİGROS', -45.9],
+ ])
+ const wb = XLSX.utils.book_new()
+ XLSX.utils.book_append_sheet(wb, ws, 'Hareketler')
+ const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
+ const sheets = await readSheets(new File([out], 'ekstre.xlsx'))
+ expect(sheets[0].name).toBe('Hareketler')
+ expect(sheets[0].rows[2]).toEqual([46339, 'MİGROS', -45.9])
+ expect(parseCellDate(sheets[0].rows[2][0])).toBe('2026-11-13')
+ const n = normalize(sheets[0].rows, 1, {
+ date: 0,
+ description: 1,
+ amount: 2,
+ credit: -1,
+ mode: 'signed-out-negative',
+ })
+ expect(n.rows).toEqual([
+ { date: '2026-11-13', description: 'MİGROS', amount: 4_590, direction: 'out' },
+ ])
+ })
+
+ it('CSV file in Windows-1254', async () => {
+ const bytes = new Uint8Array([0x54, 0x3b, 0x41, 0x0a, 0x31, 0x3b, 0xde]) // "T;A\n1;Ş"
+ const sheets = await readSheets(new File([bytes], 'ekstre.csv'))
+ expect(sheets[0].rows).toEqual([
+ ['T', 'A'],
+ ['1', 'Ş'],
+ ])
+ })
+})
diff --git a/frontend/src/lib/read-sheet.ts b/frontend/src/lib/read-sheet.ts
new file mode 100644
index 0000000..552d8ca
--- /dev/null
+++ b/frontend/src/lib/read-sheet.ts
@@ -0,0 +1,66 @@
+/**
+ * Ekstre dosyasını tablo satırlarına çevirir (tarayıcıda; dosya hiçbir yere gönderilmez).
+ * - Excel (.xlsx, .xls, bankaların "HTML-xls"i): SheetJS — yalnızca gerektiğinde yüklenir.
+ * Hücreler ham değer: tarih ve tutar sayı olarak gelir (`statement.ts` çözer).
+ * - CSV / metin: kendi ayrıştırıcımız (SheetJS CSV'de "1.250,50" gibi Türkçe sayıları
+ * yanlış yorumlayabilir); kodlama `decodeText` (UTF-8 / Windows-1254).
+ */
+import { decodeText } from './decode'
+import type { Cell } from './statement'
+
+export type Sheet = { name: string; rows: Cell[][] }
+
+/** CSV metni → satırlar; ayırıcı ilk satırdan (`;`, sekme, `,`) */
+export function parseCsvText(text: string): string[][] {
+ const first = text.split(/\r?\n/, 1)[0] ?? ''
+ const delim = [';', '\t', ','].reduce((a, d) =>
+ first.split(d).length > first.split(a).length ? d : a,
+ )
+ const rows: string[][] = []
+ let row: string[] = []
+ let cell = ''
+ let q = false
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i]
+ if (q) {
+ if (c === '"' && text[i + 1] === '"') {
+ cell += '"'
+ i++
+ } else if (c === '"') q = false
+ else cell += c
+ } else if (c === '"' && cell === '') q = true
+ else if (c === delim) {
+ row.push(cell)
+ cell = ''
+ } else if (c === '\n') {
+ row.push(cell)
+ rows.push(row)
+ row = []
+ cell = ''
+ } else if (c !== '\r') cell += c
+ }
+ if (cell !== '' || row.length) {
+ row.push(cell)
+ rows.push(row)
+ }
+ return rows
+}
+
+export async function readSheets(file: File): Promise {
+ const buf = await file.arrayBuffer()
+ if (/\.(csv|txt)$/i.test(file.name)) {
+ return [{ name: file.name, rows: parseCsvText(decodeText(buf).text) }]
+ }
+ const XLSX = await import('xlsx')
+ const wb = XLSX.read(buf, { type: 'array', cellDates: false, raw: false })
+ return wb.SheetNames.map((name) => ({
+ name,
+ rows: XLSX.utils.sheet_to_json(wb.Sheets[name], {
+ header: 1,
+ raw: true,
+ defval: null,
+ // Boş satırlar korunur → satır numaraları Excel’dekiyle aynı
+ blankrows: true,
+ }),
+ }))
+}
diff --git a/frontend/src/lib/statement.test.ts b/frontend/src/lib/statement.test.ts
new file mode 100644
index 0000000..7648f31
--- /dev/null
+++ b/frontend/src/lib/statement.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from 'vitest'
+import {
+ applyProfile,
+ detectHeaderRow,
+ excelSerialToIso,
+ guessMapping,
+ normalize,
+ parseAmount,
+ parseCellDate,
+ ruleKey,
+ type Cell,
+} from './statement'
+
+describe('statement parsing', () => {
+ it('dates: text, Excel serial, invalid', () => {
+ expect(parseCellDate('13.11.2026')).toBe('2026-11-13')
+ expect(parseCellDate('3/1/2026 14:22')).toBe('2026-01-03')
+ expect(parseCellDate('2026-11-13')).toBe('2026-11-13')
+ expect(parseCellDate('13-11-2026')).toBe('2026-11-13')
+ expect(excelSerialToIso(46339)).toBe('2026-11-13')
+ expect(parseCellDate(46339.6)).toBe('2026-11-13') // saat kısmı
+ expect(parseCellDate('31.02.2026')).toBeNull()
+ expect(parseCellDate('Toplam')).toBeNull()
+ expect(parseCellDate(123)).toBeNull()
+ })
+
+ it('amounts in Turkish and English styles', () => {
+ expect(parseAmount('1.250,50')).toBe(125_050)
+ expect(parseAmount('-45,90 TL')).toBe(-4_590)
+ expect(parseAmount('1,250.50')).toBe(125_050)
+ expect(parseAmount('(45,90)')).toBe(-4_590)
+ expect(parseAmount('45,90-')).toBe(-4_590)
+ expect(parseAmount('1.000')).toBe(100_000)
+ expect(parseAmount('12.5')).toBe(1_250)
+ expect(parseAmount(-1250.5)).toBe(-125_050)
+ expect(parseAmount('+20')).toBe(2_000)
+ expect(parseAmount('')).toBeNull()
+ expect(parseAmount('abc')).toBeNull()
+ })
+
+ it('finds the table header below bank info lines and guesses columns', () => {
+ const rows: Cell[][] = [
+ ['GARANTİ BBVA', null, null],
+ ['Hesap No: 123', null, null],
+ [],
+ ['İşlem Tarihi', 'Açıklama', 'Tutar', 'Bakiye'],
+ ['13.11.2026', 'MIGROS', '-45,90', '1.000,00'],
+ ]
+ expect(detectHeaderRow(rows)).toBe(3)
+ expect(guessMapping(rows[3])).toEqual({ date: 0, description: 1, amount: 2 })
+ expect(guessMapping(['Tarih', 'İşlem', 'Borç', 'Alacak'])).toMatchObject({
+ date: 0,
+ amount: 2,
+ credit: 3,
+ mode: 'debit-credit',
+ })
+ })
+
+ it('normalizes direction by mode and skips unreadable lines', () => {
+ const rows: Cell[][] = [
+ ['Tarih', 'Açıklama', 'Tutar'],
+ ['13.11.2026', ' MIGROS ISTANBUL ', '45,90'],
+ ['14.11.2026', 'IADE', '-10,00'],
+ ['', 'Toplam', '35,90'],
+ ['15.11.2026', 'SIFIR', '0'],
+ ]
+ const card = normalize(rows, 0, {
+ date: 0,
+ description: 1,
+ amount: 2,
+ credit: -1,
+ mode: 'signed-out-positive',
+ })
+ expect(card.rows).toEqual([
+ { date: '2026-11-13', description: 'MIGROS ISTANBUL', amount: 4_590, direction: 'out' },
+ { date: '2026-11-14', description: 'IADE', amount: 1_000, direction: 'in' },
+ ])
+ expect(card.skipped).toEqual([4, 5])
+ const acc = normalize(rows, 0, {
+ date: 0,
+ description: 1,
+ amount: 2,
+ credit: -1,
+ mode: 'signed-out-negative',
+ })
+ expect(acc.rows.map((r) => r.direction)).toEqual(['in', 'out'])
+ const dc = normalize(
+ [
+ ['T', 'A', 'Borç', 'Alacak'],
+ ['01.11.2026', 'MAAŞ', '', '45.000,00'],
+ ['02.11.2026', 'KİRA', '15.000,00', ''],
+ ],
+ 0,
+ { date: 0, description: 1, amount: 2, credit: 3, mode: 'debit-credit' },
+ )
+ expect(dc.rows.map((r) => [r.direction, r.amount])).toEqual([
+ ['in', 4_500_000],
+ ['out', 1_500_000],
+ ])
+ })
+
+ it('rule key equals the backend one', () => {
+ expect(ruleKey('MİGROS 1234 İSTANBUL TR')).toBe('migros istanbul')
+ expect(ruleKey('A101 MAGAZACILIK*456')).toBe('a101 magazacilik')
+ expect(ruleKey('ÖDEME ŞÜKRÜ')).toBe('odeme sukru')
+ expect(ruleKey('12/11 *')).toBe('')
+ })
+
+ it('profiles match columns by name, not position', () => {
+ const p = {
+ name: 'Kart',
+ source: 'card' as const,
+ columns: { date: 'İşlem Tarihi', description: 'Açıklama', amount: 'Tutar' },
+ mode: 'signed-out-positive' as const,
+ }
+ expect(applyProfile(p, ['Açıklama', 'Tutar', 'İŞLEM TARİHİ'])).toMatchObject({
+ date: 2,
+ description: 0,
+ amount: 1,
+ })
+ expect(applyProfile(p, ['Tarih', 'Açıklama', 'Tutar'])).toBeNull()
+ })
+})
diff --git a/frontend/src/lib/statement.ts b/frontend/src/lib/statement.ts
new file mode 100644
index 0000000..bebb9e6
--- /dev/null
+++ b/frontend/src/lib/statement.ts
@@ -0,0 +1,251 @@
+/**
+ * Banka / kart ekstresi okuma (saf hesaplar). Dosya tarayıcıda okunur; sunucuya yalnızca
+ * tarih, açıklama, tutar (kuruş) ve yön gider (`/bank-import/*`).
+ *
+ * - Hücreler: Excel'den sayı (tarih seri numarası, tutar) ya da metin; CSV'den metin.
+ * - Tutar: tek sütun (işaretli; kartta artı = harcama, hesapta eksi = harcama) ya da Borç/Alacak.
+ * - Eşleştirme sütun **adlarıyla** profil olarak saklanır (her ay aynı banka dosyası).
+ */
+
+export type Cell = string | number | boolean | null | undefined
+
+export type Source = 'card' | 'account'
+/** `signed-out-negative`: eksi = çıkış (hesap) · `signed-out-positive`: artı = çıkış (kart) · `debit-credit`: Borç/Alacak */
+export type AmountMode = 'signed-out-negative' | 'signed-out-positive' | 'debit-credit'
+
+export type Mapping = {
+ date: number
+ description: number
+ amount: number
+ /** `debit-credit` için */
+ credit: number
+ mode: AmountMode
+}
+
+export type StatementRow = {
+ date: string
+ description: string
+ /** Kuruş, > 0 */
+ amount: number
+ direction: 'in' | 'out'
+}
+
+const text = (c: Cell) => (c === null || c === undefined ? '' : String(c).trim())
+
+// ---------- Tarih ----------
+
+/** Excel seri numarası (1900 sistemi) → `"YYYY-MM-DD"` (saat dilimi kaymasız) */
+export function excelSerialToIso(n: number): string | null {
+ if (!Number.isFinite(n) || n < 20_000 || n > 80_000) return null // ~1954–2119
+ const ms = Math.round((Math.floor(n) - 25_569) * 86_400_000) // 25569 = 1970-01-01
+ return new Date(ms).toISOString().slice(0, 10)
+}
+
+/** `13.11.2026` · `13/11/2026` · `13-11-2026` · `2026-11-13` (+ saat) · Excel seri numarası */
+export function parseCellDate(c: Cell): string | null {
+ if (typeof c === 'number') return excelSerialToIso(c)
+ const s = text(c).split(/[\sT]/)[0]
+ let y: number, m: number, d: number
+ let mt = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(s)
+ if (mt) [y, m, d] = [Number(mt[1]), Number(mt[2]), Number(mt[3])]
+ else {
+ mt = /^(\d{1,2})[./-](\d{1,2})[./-](\d{4})$/.exec(s)
+ if (!mt) return null
+ ;[d, m, y] = [Number(mt[1]), Number(mt[2]), Number(mt[3])]
+ }
+ const iso = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
+ const dt = new Date(`${iso}T00:00:00Z`)
+ return dt.getUTCFullYear() === y && dt.getUTCMonth() + 1 === m && dt.getUTCDate() === d
+ ? iso
+ : null
+}
+
+// ---------- Tutar ----------
+
+/**
+ * Hücre → işaretli kuruş. `1.250,50` · `-45,90 TL` · `1,250.50` · `(45,90)` · `45,90-` · sayı.
+ * Hem nokta hem virgül varsa sondaki ondalıktır; yalnız virgül → ondalık;
+ * yalnız tek nokta + tam 3 rakam → binlik (`1.000`), değilse ondalık (`12.50`).
+ */
+export function parseAmount(c: Cell): number | null {
+ if (typeof c === 'number') return Number.isFinite(c) ? Math.round(c * 100) : null
+ let s = text(c).replace(/\s|TL|TRY|₺/gi, '')
+ if (!s) return null
+ let neg = false
+ if (/^\(.*\)$/.test(s)) [neg, s] = [true, s.slice(1, -1)]
+ if (s.endsWith('-')) [neg, s] = [true, s.slice(0, -1)]
+ if (s.startsWith('-')) [neg, s] = [!neg, s.slice(1)]
+ else if (s.startsWith('+')) s = s.slice(1)
+ let int: string
+ let frac = ''
+ const lastDot = s.lastIndexOf('.')
+ const lastComma = s.lastIndexOf(',')
+ if (lastDot >= 0 && lastComma >= 0) {
+ const dec = lastDot > lastComma ? '.' : ','
+ const [i, f] = [
+ s.slice(0, Math.max(lastDot, lastComma)),
+ s.slice(Math.max(lastDot, lastComma) + 1),
+ ]
+ int = i.replace(dec === '.' ? /,/g : /\./g, '')
+ frac = f
+ } else if (lastComma >= 0) {
+ const parts = s.split(',')
+ if (parts.length !== 2) return null
+ ;[int, frac] = parts
+ } else if (lastDot >= 0) {
+ const parts = s.split('.')
+ if (parts.length === 2 && !(parts[1].length === 3 && parts[0] !== '0' && parts[0] !== '')) {
+ ;[int, frac] = parts
+ } else int = parts.join('')
+ } else int = s
+ if (int === '') int = '0'
+ if (!/^\d+$/.test(int) || !/^\d{0,2}$/.test(frac)) return null
+ const v = Number(int) * 100 + Number(frac.padEnd(2, '0'))
+ return neg ? -v : v
+}
+
+// ---------- Başlık satırı ----------
+
+/** İlk "tablo başlığı": ≥ 3 dolu hücre ve hemen altında tarih içeren bir satır */
+export function detectHeaderRow(rows: Cell[][]): number {
+ for (let i = 0; i < Math.min(rows.length - 1, 40); i++) {
+ const filled = rows[i].filter((c) => text(c) !== '').length
+ if (filled >= 3 && rows[i + 1].some((c) => parseCellDate(c) !== null)) return i
+ }
+ return 0
+}
+
+const norm = (s: string) => fold(s).replace(/[^a-z0-9]/g, '')
+
+/** Başlık adlarından tahmini eşleştirme (Türk bankalarının yaygın adları) */
+export function guessMapping(header: Cell[]): Partial {
+ const h = header.map((c) => norm(text(c)))
+ const find = (...names: string[]) => {
+ const i = h.findIndex((x) => names.some((n) => x === n || x.startsWith(n)))
+ return i >= 0 ? i : undefined
+ }
+ const debit = find('borc', 'cikan', 'harcama')
+ const credit = find('alacak', 'giren', 'yatan')
+ return {
+ date: find('islemtarihi', 'tarih', 'date'),
+ description: find('aciklama', 'islemaciklamasi', 'description', 'detay', 'isyeri'),
+ ...(debit !== undefined && credit !== undefined
+ ? { amount: debit, credit, mode: 'debit-credit' as const }
+ : { amount: find('tutar', 'islemtutari', 'amount', 'miktar') }),
+ }
+}
+
+// ---------- Satırlar ----------
+
+export type Normalized = {
+ rows: StatementRow[]
+ /** Okunamayan (tarih/tutar yok, sıfır) satır numaraları, dosyadaki (1'den) */
+ skipped: number[]
+}
+
+export function normalize(rows: Cell[][], headerRow: number, m: Mapping): Normalized {
+ const out: StatementRow[] = []
+ const skipped: number[] = []
+ rows.slice(headerRow + 1).forEach((r, i) => {
+ const line = headerRow + 2 + i
+ if (r.every((c) => text(c) === '')) return
+ const date = parseCellDate(r[m.date])
+ let signed: number | null
+ if (m.mode === 'debit-credit') {
+ const debit = parseAmount(r[m.amount]) ?? 0
+ const credit = parseAmount(r[m.credit]) ?? 0
+ signed = debit !== 0 ? -Math.abs(debit) : credit !== 0 ? Math.abs(credit) : null
+ } else {
+ const v = parseAmount(r[m.amount])
+ signed = v === null ? null : m.mode === 'signed-out-positive' ? -v : v
+ }
+ if (!date || !signed) {
+ skipped.push(line)
+ return
+ }
+ out.push({
+ date,
+ description: text(r[m.description]).replace(/\s+/g, ' '),
+ amount: Math.abs(signed),
+ direction: signed < 0 ? 'out' : 'in',
+ })
+ })
+ return { rows: out, skipped }
+}
+
+// ---------- Açıklama anahtarı (backend `bank_import::rule_key` ile aynı) ----------
+
+const FOLD: Record = {
+ ı: 'i',
+ ş: 's',
+ ğ: 'g',
+ ü: 'u',
+ ö: 'o',
+ ç: 'c',
+ â: 'a',
+ î: 'i',
+ û: 'u',
+}
+
+export function fold(s: string): string {
+ return s
+ .replace(/İ/g, 'i')
+ .toLowerCase()
+ .replace(/̇/g, '')
+ .replace(/[ışğüöçâîû]/g, (c) => FOLD[c] ?? c)
+}
+
+export function ruleKey(desc: string): string {
+ return fold(desc)
+ .split(/[^\p{L}\p{N}]+/u)
+ .filter((w) => w.length >= 2 && !/^\d+$/.test(w))
+ .slice(0, 2)
+ .join(' ')
+}
+
+// ---------- Profiller (bu tarayıcıda) ----------
+
+export type Profile = {
+ name: string
+ source: Source
+ /** Sütun adları (sıra değişse de bulunur) */
+ columns: { date: string; description: string; amount: string; credit?: string }
+ mode: AmountMode
+ paymentMethodId?: string
+}
+
+const KEY = 'kakeibo.statement.profiles'
+
+export function loadProfiles(): Profile[] {
+ try {
+ const v = JSON.parse(localStorage.getItem(KEY) ?? '[]')
+ return Array.isArray(v) ? v : []
+ } catch {
+ return []
+ }
+}
+
+export function saveProfile(p: Profile) {
+ const list = loadProfiles().filter((x) => x.name !== p.name)
+ try {
+ localStorage.setItem(KEY, JSON.stringify([p, ...list].slice(0, 20)))
+ } catch {
+ // Gizli pencere vb.: profil kaydedilemez, akış sürer
+ }
+}
+
+/** Profili başlığa uygula: tüm sütun adları bulunursa eşleştirme */
+export function applyProfile(p: Profile, header: Cell[]): Mapping | null {
+ const h = header.map((c) => norm(text(c)))
+ const at = (name?: string) => (name ? h.indexOf(norm(name)) : -1)
+ const m = {
+ date: at(p.columns.date),
+ description: at(p.columns.description),
+ amount: at(p.columns.amount),
+ credit: p.mode === 'debit-credit' ? at(p.columns.credit) : -1,
+ mode: p.mode,
+ }
+ if (m.date < 0 || m.description < 0 || m.amount < 0) return null
+ if (p.mode === 'debit-credit' && m.credit < 0) return null
+ return m
+}
diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts
index 27d12fb..65268a1 100644
--- a/frontend/src/lib/strings.ts
+++ b/frontend/src/lib/strings.ts
@@ -250,6 +250,8 @@ export const t = {
},
ledger: {
+ refund: 'İade',
+ refundHint: 'İade kaydı: bu tutar kategorinin harcamasından düşülür.',
readOnly:
'Bu dönem kapalı; kayıtlar yalnızca görüntülenebilir. Düzeltme için hane sahibi Değerlendirme’den yeniden açabilir.',
week: (n: number, count: number) => `${n}. hafta / ${count}`,
@@ -612,6 +614,82 @@ export const t = {
nothing: 'Eklenecek yeni satır yok.',
},
+ statement: {
+ title: 'Ekstre içe aktar',
+ description: 'Banka hesabı ve kredi kartı ekstrelerini deftere aktar.',
+ entry: 'Banka / kart ekstresi',
+ entryHint:
+ 'Excel ya da CSV ekstreyi yükle; satırlar harcama, iade, gelir ya da yok say olarak ayrılır. Dönemi olmayan aylar için arşiv dönemi açılır.',
+ open: 'Ekstre içe aktar',
+ back: 'Ayarlar',
+ steps: ['Dosya', 'Eşleştir', 'Gözden geçir'],
+ fileHint:
+ 'İnternet bankacılığından “Excel’e aktar” ile indirdiğin dosyayı seç (.xlsx, .xls ya da .csv). Dosya bu cihazda okunur; yalnızca tarih, açıklama ve tutar gönderilir.',
+ choose: 'Dosya seç',
+ reading: 'Dosya okunuyor…',
+ readError: 'Dosya okunamadı. Excel (.xlsx/.xls) ya da CSV olduğundan emin ol.',
+ sheet: 'Sayfa',
+ headerRow: 'Başlık satırı',
+ rowN: (n: number) => `${n}. satır`,
+ profile: 'Kayıtlı eşleştirme',
+ noProfile: '— Yeni —',
+ profileApplied: (name: string) => `“${name}” eşleştirmesi uygulandı`,
+ source: 'Ekstre türü',
+ card: 'Kredi kartı',
+ account: 'Banka hesabı',
+ dateCol: 'Tarih sütunu',
+ descCol: 'Açıklama sütunu',
+ amountMode: 'Tutar',
+ modes: {
+ 'signed-out-positive': 'Tek sütun · artı = harcama (kart)',
+ 'signed-out-negative': 'Tek sütun · eksi = harcama (hesap)',
+ 'debit-credit': 'İki sütun · Borç / Alacak',
+ },
+ amountCol: 'Tutar sütunu',
+ debitCol: 'Borç (çıkan) sütunu',
+ creditCol: 'Alacak (giren) sütunu',
+ pick: 'Seç…',
+ method: 'Ödeme türü (harcamalara yazılır)',
+ noMethod: 'Yok',
+ profileName: 'Eşleştirmeyi kaydet (ad)',
+ profileNamePh: 'ör. Garanti Kart',
+ preview: 'Dosyadan ilk satırlar',
+ continue: 'Devam',
+ checking: 'Satırlar denetleniyor…',
+ noRows: 'Bu eşleştirmeyle okunabilen satır yok. Başlık satırını ve sütunları kontrol et.',
+ skipped: (n: number) =>
+ `${n} satır okunamadı (tarih ya da tutar yok; ör. toplam satırları) ve atlandı.`,
+ kinds: { expense: 'Harcama', refund: 'İade', income: 'Gelir', ignore: 'Yok say' },
+ category: 'Kategori',
+ learned: 'öğrenildi',
+ duplicate: 'Zaten aktarıldı',
+ archivesTitle: 'Açılacak arşiv dönemleri',
+ archivesHint:
+ 'Bu aylar için dönem yok; geçmiş (arşiv) dönem olarak açılacak. Arşivler kapatılmaz ve bütçe önerilerinde kullanılır.',
+ summary: (n: number, d: number, b: number) =>
+ `${n} satır · ${d} zaten aktarılmış · ${b} aktarılamaz`,
+ filters: { all: 'Tümü', missing: 'Kategorisiz', ignore: 'Yok sayılan', skip: 'Atlanacak' },
+ missing: (n: number) => `${n} satırda kategori seçilmedi`,
+ appliedSimilar: (n: number) => `${n} benzer satıra da uygulandı`,
+ commit: (n: number) => `${n} satırı aktar`,
+ committing: 'Aktarılıyor…',
+ page: (a: number, b: number, n: number) => `${a}–${b} / ${n}`,
+ prev: 'Önceki',
+ next: 'Sonraki',
+ done: 'Aktarım tamamlandı',
+ result: {
+ entries: (n: number) => `${n} harcama`,
+ refunds: (n: number) => `${n} iade`,
+ incomes: (n: number) => `${n} gelir`,
+ ignored: (n: number) => `${n} yok sayıldı`,
+ duplicates: (n: number) => `${n} zaten vardı`,
+ archives: (names: string) => `Açılan arşiv dönemleri: ${names}`,
+ },
+ errorsTitle: 'Aktarılamayan satırlar',
+ toReports: 'Raporlara git',
+ another: 'Başka ekstre',
+ },
+
periods: {
picker: 'Dönem',
status: {
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 5579651..9fcf374 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -17,6 +17,7 @@ import { ReviewPage } from '@/features/review/page'
import { AssetPage } from '@/features/savings/asset-page'
import { SavingsPage } from '@/features/savings/page'
import { SettingsPage } from '@/features/settings/page'
+import { StatementPage } from '@/features/statement/page'
const routes: RouteObject[] = [
// Oturumsuz
@@ -44,6 +45,7 @@ const routes: RouteObject[] = [
{ path: paths.closePeriod, element: },
{ path: paths.reports, element: },
{ path: paths.settings, element: },
+ { path: paths.statement, element: },
{ path: paths.more, element: },
{ path: '*', element: },
],
diff --git a/frontend/vendor/xlsx-0.20.3.tgz b/frontend/vendor/xlsx-0.20.3.tgz
new file mode 100644
index 0000000000000000000000000000000000000000..b9df84a5c07d3bb78d75e310b7931e3dbf56783e
GIT binary patch
literal 2409319
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 |