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 0000000..b9df84a
Binary files /dev/null and b/frontend/vendor/xlsx-0.20.3.tgz differ
|