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 ( +
+

{s.fileHint}

+ { + const f = e.target.files?.[0] + if (f) void read(f) + }} + /> + + +
+ ) +} + +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) => ( + + ))} + + + + {rows.slice(headerRow + 1, headerRow + 7).map((r, i) => ( + + {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)) && ' 💰'} +
+ {/* 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])} +
+
+
+ + +
+ + +
+
+ ) +} + +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 && ( +
+ + + {s.page(page * PAGE + 1, Math.min(shown.length, page * PAGE + PAGE), shown.length)} + + +
+ )} + + {missing.length > 0 &&

{s.missing(missing.length)}

} + +
+ + +
+
+ ) +} + +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} +
  • + ))} +
+
+ )} +
+ + +
+
+
+ ) +} 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