From d1a24afc3cd528892f2ccf99a2cff621e5d79319 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 13 Nov 2026 08:33:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(ekstre):=20banka/kart=20ekstresi=20i=C3=A7?= =?UTF-8?q?e=20aktarma=20altyap=C4=B1s=C4=B1=20(ar=C5=9Fiv=20d=C3=B6nemler?= =?UTF-8?q?i,=20iade,=20=C3=B6=C4=9Frenen=20kurallar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Arşiv dönemi durumu: geçmiş aylar için; kapatılmaz, devir zincirinde atlanır, bütçe önerilerini besler - POST /bank-import/preview ve /commit: harcama / iade (eksi) / gelir (plan) / yoksay - Tekrar tespiti (import_key), açıklamadan öğrenen kategori kuralları, ödeme/virman ipuçları - Kapalı dönem ve gelecek tarih satır hatası; iade düzenlemede eksi kalır Co-Authored-By: Claude Opus 5.5 --- backend/src/db/indexes.rs | 6 + backend/src/db/mod.rs | 3 + backend/src/models/period.rs | 5 + backend/src/routes/bank_import.rs | 221 +++++++ backend/src/routes/mod.rs | 9 + backend/src/services/bank_import.rs | 613 ++++++++++++++++++ backend/src/services/budget.rs | 8 +- backend/src/services/closing.rs | 13 +- backend/src/services/entry.rs | 14 +- backend/src/services/mod.rs | 1 + backend/src/services/period.rs | 38 ++ backend/tests/bank_import.rs | 376 +++++++++++ docs/DATA_MODEL.md | 12 +- docs/MEMORY.md | 4 +- docs/PHASES.md | 2 +- frontend/src/features/planning/plan-read.tsx | 2 +- frontend/src/features/review/close-page.tsx | 6 +- .../src/features/review/period-status.tsx | 8 + frontend/src/lib/strings.ts | 11 +- frontend/src/types/BankArchive.ts | 7 + frontend/src/types/BankCommitRequest.ts | 8 + frontend/src/types/BankImportReport.ts | 8 + frontend/src/types/BankPreview.ts | 9 + frontend/src/types/BankPreviewRequest.ts | 4 + frontend/src/types/BankPreviewRow.ts | 23 + frontend/src/types/BankRowError.ts | 7 + frontend/src/types/BankRowIn.ts | 15 + frontend/src/types/PeriodStatus.ts | 2 +- 28 files changed, 1422 insertions(+), 13 deletions(-) create mode 100644 backend/src/routes/bank_import.rs create mode 100644 backend/src/services/bank_import.rs create mode 100644 backend/tests/bank_import.rs create mode 100644 frontend/src/types/BankArchive.ts create mode 100644 frontend/src/types/BankCommitRequest.ts create mode 100644 frontend/src/types/BankImportReport.ts create mode 100644 frontend/src/types/BankPreview.ts create mode 100644 frontend/src/types/BankPreviewRequest.ts create mode 100644 frontend/src/types/BankPreviewRow.ts create mode 100644 frontend/src/types/BankRowError.ts create mode 100644 frontend/src/types/BankRowIn.ts diff --git a/backend/src/db/indexes.rs b/backend/src/db/indexes.rs index 36860be..4b5f6a2 100644 --- a/backend/src/db/indexes.rs +++ b/backend/src/db/indexes.rs @@ -69,6 +69,8 @@ fn definitions() -> Vec<(&'static str, Vec)> { vec![ idx(doc! { "household_id": 1, "period_id": 1, "date": 1 }), idx(doc! { "household_id": 1, "date": 1 }), + // Ekstre içe aktarmada tekrar tespiti + idx(doc! { "household_id": 1, "import_key": 1 }), ], ), ( @@ -83,6 +85,10 @@ fn definitions() -> Vec<(&'static str, Vec)> { col::ASSET_TRANSACTIONS, vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })], ), + ( + col::IMPORT_RULES, + vec![idx(doc! { "household_id": 1, "pattern": 1 })], + ), ( col::EXCHANGE_RATES, vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })], diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 9a0c87b..4b307af 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -70,6 +70,8 @@ pub mod col { pub const ASSETS: &str = "assets"; pub const ASSET_TRANSACTIONS: &str = "asset_transactions"; pub const EXCHANGE_RATES: &str = "exchange_rates"; + /// Ekstre içe aktarmada öğrenilen açıklama → tür/kategori kuralları + pub const IMPORT_RULES: &str = "import_rules"; pub const SCHEMA_MIGRATIONS: &str = "schema_migrations"; /// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var). @@ -87,5 +89,6 @@ pub mod col { NOTES, ASSETS, ASSET_TRANSACTIONS, + IMPORT_RULES, ]; } diff --git a/backend/src/models/period.rs b/backend/src/models/period.rs index 12a6db4..94ff70b 100644 --- a/backend/src/models/period.rs +++ b/backend/src/models/period.rs @@ -16,6 +16,10 @@ pub enum PeriodStatus { /// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir). /// "Yeniden kapat" ile devir yeniden hesaplanır. Correcting, + /// Geçmiş (arşiv): ekstre içe aktarımıyla yıl başından beri olan aylar için açılır. + /// Yalnızca kayıt tutar: plan/bütçe zorunlu değil, kapatılmaz, devir üretmez ve + /// devir zincirinde atlanır; bütçe önerilerini ("önceki dönem", "son 3 ay") besler. + Archived, } impl PeriodStatus { @@ -24,6 +28,7 @@ impl PeriodStatus { PeriodStatus::Open => "open", PeriodStatus::Closed => "closed", PeriodStatus::Correcting => "correcting", + PeriodStatus::Archived => "archived", } } diff --git a/backend/src/routes/bank_import.rs b/backend/src/routes/bank_import.rs new file mode 100644 index 0000000..452be2a --- /dev/null +++ b/backend/src/routes/bank_import.rs @@ -0,0 +1,221 @@ +//! Banka / kart ekstresi içe aktarma (hanenin tüm üyeleri). Dosya tarayıcıda okunur; +//! buraya eşleştirilmiş satırlar gelir. +//! +//! | Yöntem | Yol | İş | +//! |---|---|---| +//! | POST | `/api/bank-import/preview` | satır başına öneri (tür, kategori), tekrar, dönem; açılacak arşiv dönemleri | +//! | POST | `/api/bank-import/commit` | kullanıcının seçimiyle aktar; seçimleri kural olarak öğren | + +use axum::{Json, extract::State}; +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use validator::Validate; + +use crate::{ + auth::AuthUser, + error::{AppError, AppResult}, + extract::ValidJson, + services::{ + bank_import::{self, Direction, Kind, Row, Source, Target}, + calendar::{default_period_name, format_date, parse_date}, + }, + state::AppState, +}; + +#[derive(Deserialize, TS)] +#[ts(export, rename = "BankRowIn")] +pub struct RowIn { + /// `"YYYY-MM-DD"` + date: String, + description: String, + /// Kuruş, > 0 + #[ts(type = "number")] + amount: i64, + #[ts(type = "\"in\" | \"out\"")] + direction: Direction, + /// Yalnızca aktarmada (kullanıcının seçimi) + #[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"", optional)] + kind: Option, + #[ts(optional)] + category_id: Option, +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "BankPreviewRequest")] +pub struct PreviewRequest { + #[ts(type = "\"card\" | \"account\"")] + source: Source, + rows: Vec, +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "BankCommitRequest", optional_fields)] +pub struct CommitRequest { + /// Kayıtlara yazılacak ödeme türü (ör. Kredi Kartı) + payment_method_id: Option, + rows: Vec, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "BankPreviewRow")] +struct PreviewRowDto { + #[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"")] + kind: &'static str, + category_id: Option, + /// Öneri hanenin öğrendiği kuraldan + from_rule: bool, + /// Daha önce aktarılmış (atlanacak) + duplicate: bool, + /// `existing` · `archive` · `blocked` + #[ts(type = "\"existing\" | \"archive\" | \"blocked\"")] + period: &'static str, + /// Dönem adı (var olan ya da açılacak arşiv) + period_name: Option, + /// Engel nedeni + message: Option, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "BankPreview")] +pub struct PreviewDto { + rows: Vec, + /// Açılacak arşiv dönemleri + archives: Vec, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "BankArchive")] +struct ArchiveDto { + name: String, + start_date: String, + /// Son gün, dahil + last_date: String, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "BankImportReport")] +pub struct ReportDto { + entries: u32, + refunds: u32, + incomes: u32, + ignored: u32, + duplicates: u32, + /// Açılan arşiv dönemleri + archives: Vec, + errors: Vec, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "BankRowError")] +struct RowError { + /// Satır sırası (0'dan) + index: u32, + message: String, +} + +fn rows_of(rows: Vec) -> AppResult> { + rows.into_iter() + .map(|r| { + let category_id = match r.category_id.as_deref().map(str::trim) { + None | Some("") => None, + Some(id) => Some( + Uuid::parse_str(id) + .map_err(|_| AppError::BadRequest("Kategori geçersiz".into()))?, + ), + }; + Ok(Row { + date: r.date.trim().to_string(), + description: r.description, + amount: r.amount, + direction: r.direction, + kind: r.kind, + category_id, + }) + }) + .collect() +} + +fn archive_name(start: &str, end: &str) -> String { + match (parse_date(start), parse_date(end)) { + (Some(s), Some(e)) => default_period_name(s, e), + _ => start.to_string(), + } +} + +pub async fn preview( + State(s): State, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult> { + let rows = rows_of(req.rows)?; + let p = bank_import::preview(&s.db, auth.household_id, req.source, &rows).await?; + Ok(Json(PreviewDto { + rows: p + .rows + .into_iter() + .map(|r| { + let (period, period_name, message) = match r.target { + Target::Existing { name, .. } => ("existing", Some(name), None), + Target::Archive { start, end } => { + ("archive", Some(archive_name(&start, &end)), None) + } + Target::Blocked(m) => ("blocked", None, Some(m)), + }; + PreviewRowDto { + kind: r.kind.as_str(), + category_id: r.category_id.map(|c| c.to_string()), + from_rule: r.from_rule, + duplicate: r.duplicate, + period, + period_name, + message, + } + }) + .collect(), + archives: p + .archives + .into_iter() + .map(|(start, end)| { + let last = parse_date(&end) + .map_or(end.clone(), |e| format_date(e - time::Duration::days(1))); + ArchiveDto { + name: archive_name(&start, &end), + start_date: start, + last_date: last, + } + }) + .collect(), + })) +} + +pub async fn commit( + State(s): State, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult> { + let pm = match req.payment_method_id.as_deref().map(str::trim) { + None | Some("") => None, + Some(id) => Some( + Uuid::parse_str(id).map_err(|_| AppError::BadRequest("Ödeme türü geçersiz".into()))?, + ), + }; + let rows = rows_of(req.rows)?; + let r = bank_import::commit(&s.db, auth.household_id, auth.user_id, pm, &rows).await?; + Ok(Json(ReportDto { + entries: r.entries as u32, + refunds: r.refunds as u32, + incomes: r.incomes as u32, + ignored: r.ignored as u32, + duplicates: r.duplicates as u32, + archives: r.archives, + errors: r + .errors + .into_iter() + .map(|(i, message)| RowError { + index: i as u32, + message, + }) + .collect(), + })) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 7808369..d66bbbb 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -2,6 +2,7 @@ mod asset; mod auth; +mod bank_import; mod budget; mod catalog; mod entry; @@ -97,6 +98,14 @@ pub fn router(state: AppState) -> Router { "/export/asset-transactions.csv", get(transfer::export_asset_txs), ) + .route( + "/bank-import/preview", + post(bank_import::preview).layer(DefaultBodyLimit::max(5 * 1024 * 1024)), + ) + .route( + "/bank-import/commit", + post(bank_import::commit).layer(DefaultBodyLimit::max(5 * 1024 * 1024)), + ) .route( "/import/{kind}", // Büyük CSV (≤ 20.000 satır) için gövde sınırı 5 MB diff --git a/backend/src/services/bank_import.rs b/backend/src/services/bank_import.rs new file mode 100644 index 0000000..c797b16 --- /dev/null +++ b/backend/src/services/bank_import.rs @@ -0,0 +1,613 @@ +//! Banka / kredi kartı ekstresi içe aktarma (kullanıcı isteği; Faz 9 Adım 4). +//! +//! Dosya tarayıcıda okunur (Excel/CSV) ve sütunları eşleştirilir; buraya yalnızca +//! **tarih, açıklama, tutar, yön** gelir. Her satır bir türe ayrılır: +//! - `expense` → harcama (kategori gerekir) +//! - `refund` → **iade**: aynı kategoride eksi tutarlı kayıt (harcamadan düşer) +//! - `income` → dönemin planına **gelir** kalemi +//! - `ignore` → aktarılmaz (kart borcu ödemesi, kendi hesaplar arası virman…) +//! +//! Dönem: satırın tarihini içeren yazılabilir dönem; yoksa takvim ayı kadar **arşiv** dönemi +//! açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Kapalı dönem ve gelecek tarih → hata. +//! Tekrar: `import_key` (tarih|yön|tutar|sadeleştirilmiş açıklama) — aynı ekstre ya da örtüşen +//! iki ekstre yüklense de çift kayıt olmaz. Öneri: hanenin öğrenilmiş kuralları (açıklamanın ilk +//! iki anlamlı kelimesi), yoksa hazır ipuçları ("kredi kartı ödemesi" → yoksay), yoksa yön. + +use std::collections::HashMap; + +use mongodb::{ + Database, + bson::{Bson, Document, Uuid, doc}, +}; +use serde::Deserialize; +use time::Date; + +use crate::{ + db::col, + error::{AppError, AppResult}, + models::{ + common::{new_meta, now, scope}, + period::Period, + plan_item::PlanKind, + }, + services::{ + calendar::{format_date, parse_date, today_tr}, + catalog::{self, Catalog}, + entry::MAX_DESCRIPTION, + period, + plan::{self, NewItem}, + }, +}; + +pub const MAX_ROWS: usize = 5_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Source { + /// Kredi kartı: çıkış = harcama, giriş = iade ya da ödeme + Card, + /// Banka hesabı: çıkış = harcama, giriş = gelir + Account, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Direction { + In, + Out, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + Expense, + Refund, + Income, + Ignore, +} + +impl Kind { + pub fn as_str(self) -> &'static str { + match self { + Kind::Expense => "expense", + Kind::Refund => "refund", + Kind::Income => "income", + Kind::Ignore => "ignore", + } + } +} + +#[derive(Debug, Clone)] +pub struct Row { + pub date: String, + pub description: String, + /// Kuruş, > 0 + pub amount: i64, + pub direction: Direction, + /// Commit'te kullanıcının seçimi + pub kind: Option, + pub category_id: Option, +} + +// ---------- Metin sadeleştirme ---------- + +/// Türkçe harfleri ASCII'ye indir, küçült (bankalar çoğu zaman "ODEME", "SUT" yazar) +pub fn fold(s: &str) -> String { + s.chars() + // "İ".to_lowercase() = "i" + birleşik nokta (U+0307) → önce doğrudan "i" + .map(|c| if c == 'İ' { 'i' } else { c }) + .flat_map(|c| c.to_lowercase()) + .filter(|c| *c != '\u{307}') + .map(|c| match c { + 'ı' | 'i' | 'î' => 'i', + 'ş' => 's', + 'ğ' => 'g', + 'ü' | 'û' => 'u', + 'ö' => 'o', + 'ç' => 'c', + 'â' => 'a', + c => c, + }) + .collect() +} + +/// Anlamlı kelimeler: harf/rakam dışı ayırıcı; yalnızca rakam ve tek harf atılır +fn words(desc: &str) -> Vec { + fold(desc) + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| w.len() >= 2 && !w.chars().all(|c| c.is_ascii_digit())) + .map(str::to_string) + .collect() +} + +/// Kural anahtarı: ilk iki anlamlı kelime (`MIGROS 1234 ISTANBUL TR` → `migros istanbul`) +pub fn rule_key(desc: &str) -> String { + words(desc) + .into_iter() + .take(2) + .collect::>() + .join(" ") +} + +/// 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 { + Direction::In => "in", + Direction::Out => "out", + }; + format!("{date}|{d}|{amount}|{}", words(desc).join(" ")) +} + +/// Hazır öneri (kural yoksa): ödeme/virman → yoksay; kartta giriş → iade; hesapta giriş → gelir +pub fn default_kind(source: Source, dir: Direction, desc: &str) -> Kind { + let f = words(desc).join(" "); + let transfer = [ + "kredi karti", + "kart odeme", + "kk odeme", + "virman", + "hesaplar arasi", + "kendi hesab", + "hesabima", + "borc odeme", + "odemeniz icin", + "tahsilat", + ] + .iter() + .any(|h| f.contains(h)); + match (source, dir) { + _ if transfer => Kind::Ignore, + (_, Direction::Out) => Kind::Expense, + (Source::Card, Direction::In) => { + if f.contains("odeme") { + Kind::Ignore + } else { + Kind::Refund + } + } + (Source::Account, Direction::In) => Kind::Income, + } +} + +// ---------- Dönem ---------- + +fn first_of_month(d: Date) -> Date { + d.replace_day(1).expect("1. gün") +} + +fn next_month(d: Date) -> Date { + let (y, m) = if d.month() == time::Month::December { + (d.year() + 1, time::Month::January) + } else { + (d.year(), d.month().next()) + }; + Date::from_calendar_date(y, m, 1).expect("geçerli ay") +} + +/// Satırın dönemi +#[derive(Debug, Clone, PartialEq)] +pub enum Target { + /// Var olan yazılabilir dönem (açık, düzeltmede ya da arşiv) + Existing { + id: Uuid, + name: String, + }, + /// Açılacak arşiv dönemi [start, end) + Archive { + start: String, + end: String, + }, + Blocked(String), +} + +/// Tarih → dönem: içeren dönem; yoksa takvim ayı, var olan dönemlerin dışında kalan kısmı +pub fn target(periods: &[Period], date: &str, today: &str) -> Target { + if date > today { + return Target::Blocked("Gelecek tarihli satır".into()); + } + if let Some(p) = periods + .iter() + .find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str()) + { + return if p.status.is_writable() { + Target::Existing { + id: p.id, + name: p.name.clone(), + } + } else { + Target::Blocked(format!( + "Dönem kapalı ({}); düzeltme için yeniden açın", + p.name + )) + }; + } + let d = parse_date(date).expect("doğrulanmış tarih"); + let (ms, me) = (format_date(first_of_month(d)), format_date(next_month(d))); + // Ayın içinde, tarihten önce biten en geç dönem / tarihten sonra başlayan en erken dönem + let start = periods + .iter() + .map(|p| p.end_date.as_str()) + .filter(|e| *e <= date && *e > ms.as_str()) + .max() + .map_or(ms.clone(), str::to_string); + let end = periods + .iter() + .map(|p| p.start_date.as_str()) + .filter(|s| *s > date && *s < me.as_str()) + .min() + .map_or(me.clone(), str::to_string); + Target::Archive { start, end } +} + +// ---------- Kurallar ---------- + +#[derive(Debug, Clone, Deserialize)] +struct Rule { + pattern: String, + kind: Kind, + #[serde(default)] + category_id: Option, +} + +async fn rules(db: &Database, household_id: Uuid) -> AppResult> { + let mut c = db + .collection::(col::IMPORT_RULES) + .find(scope(household_id)) + .await?; + let mut out = HashMap::new(); + while c.advance().await? { + let r = c.deserialize_current()?; + out.insert(r.pattern.clone(), r); + } + Ok(out) +} + +/// Var olan içe aktarma anahtarları (harcama + plan kalemi) → adet +async fn existing_keys(db: &Database, household_id: Uuid) -> AppResult> { + let mut out = HashMap::new(); + for coll in [col::ENTRIES, col::PLAN_ITEMS] { + let mut f = scope(household_id); + f.insert("import_key", doc! { "$type": "string" }); + let mut c = db + .collection::(coll) + .find(f) + .projection(doc! { "import_key": 1 }) + .await?; + while c.advance().await? { + if let Ok(k) = c.current().get_str("import_key") { + *out.entry(k.to_string()).or_insert(0) += 1; + } + } + } + Ok(out) +} + +fn take(m: &mut HashMap, k: &str) -> bool { + match m.get_mut(k) { + Some(n) if *n > 0 => { + *n -= 1; + true + } + _ => false, + } +} + +fn validate(r: &Row) -> Result<(), String> { + parse_date(&r.date).ok_or_else(|| format!("Tarih geçersiz: {}", r.date))?; + if r.amount <= 0 { + return Err("Tutar sıfırdan büyük olmalı".into()); + } + period::validate_amount(r.amount).map_err(|e| e.to_string())?; + Ok(()) +} + +// ---------- Önizleme ---------- + +pub struct PreviewRow { + pub kind: Kind, + pub category_id: Option, + /// Öneri hanenin öğrenilmiş kuralından mı? + pub from_rule: bool, + pub duplicate: bool, + pub target: Target, +} + +pub struct Preview { + pub rows: Vec, + /// Açılacak arşiv dönemleri [start, end) + pub archives: Vec<(String, String)>, +} + +fn check_rows(rows: &[Row]) -> AppResult<()> { + if rows.is_empty() { + return Err(AppError::BadRequest("Aktarılacak satır yok".into())); + } + if rows.len() > MAX_ROWS { + return Err(AppError::BadRequest(format!( + "Bir seferde en fazla {MAX_ROWS} satır aktarılabilir" + ))); + } + Ok(()) +} + +pub async fn preview( + db: &Database, + household_id: Uuid, + source: Source, + rows: &[Row], +) -> AppResult { + check_rows(rows)?; + let periods = period::list(db, household_id).await?; + let rules = rules(db, household_id).await?; + let mut keys = existing_keys(db, household_id).await?; + let today = format_date(today_tr()); + let mut archives: Vec<(String, String)> = Vec::new(); + let out = rows + .iter() + .map(|r| { + let target = match validate(r) { + Err(e) => Target::Blocked(e), + Ok(()) => target(&periods, &r.date, &today), + }; + if let Target::Archive { start, end } = &target + && !archives.iter().any(|(s, _)| s == start) + { + archives.push((start.clone(), end.clone())); + } + let duplicate = matches!(target, Target::Existing { .. } | Target::Archive { .. }) + && take( + &mut keys, + &import_key(&r.date, r.direction, r.amount, &r.description), + ); + let rule = rules.get(&rule_key(&r.description)); + PreviewRow { + kind: rule.map_or_else( + || default_kind(source, r.direction, &r.description), + |x| x.kind, + ), + category_id: rule.and_then(|x| x.category_id), + from_rule: rule.is_some(), + duplicate, + target, + } + }) + .collect(); + archives.sort(); + Ok(Preview { + rows: out, + archives, + }) +} + +// ---------- Aktarma ---------- + +#[derive(Debug, Default)] +pub struct Report { + pub entries: usize, + pub refunds: usize, + pub incomes: usize, + pub ignored: usize, + pub duplicates: usize, + /// Açılan arşiv dönemlerinin adları + pub archives: Vec, + /// (satır sırası 0'dan, neden) + pub errors: Vec<(usize, String)>, +} + +pub async fn commit( + db: &Database, + household_id: Uuid, + user_id: Uuid, + payment_method_id: Option, + rows: &[Row], +) -> AppResult { + check_rows(rows)?; + if let Some(pm) = payment_method_id { + catalog::find(db, household_id, Catalog::PaymentMethods, pm) + .await? + .ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?; + } + let cats = catalog::list(db, Catalog::Categories, household_id, true).await?; + let mut periods = period::list(db, household_id).await?; + let mut keys = existing_keys(db, household_id).await?; + let today = format_date(today_tr()); + let mut report = Report::default(); + let mut entries: Vec = Vec::new(); + let mut incomes: Vec<(usize, Uuid, NewItem, String)> = Vec::new(); + let mut learned: HashMap)> = HashMap::new(); + + for (i, r) in rows.iter().enumerate() { + let kind = r.kind.unwrap_or(Kind::Ignore); + let pattern = rule_key(&r.description); + if kind == Kind::Ignore { + report.ignored += 1; + if !pattern.is_empty() { + learned.insert(pattern, (kind, None)); + } + continue; + } + if let Err(e) = validate(r) { + report.errors.push((i, e)); + continue; + } + let key = import_key(&r.date, r.direction, r.amount, &r.description); + let pid = match target(&periods, &r.date, &today) { + Target::Blocked(e) => { + report.errors.push((i, e)); + continue; + } + Target::Existing { id, .. } => id, + Target::Archive { start, end } => { + let p = period::create_archive( + db, + household_id, + parse_date(&start).expect("tarih"), + parse_date(&end).expect("tarih"), + ) + .await?; + report.archives.push(p.name.clone()); + let id = p.id; + periods.push(p); + id + } + }; + if take(&mut keys, &key) { + report.duplicates += 1; + continue; + } + let desc: String = r.description.trim().chars().take(MAX_DESCRIPTION).collect(); + match kind { + Kind::Expense | Kind::Refund => { + let Some(cat) = r.category_id.and_then(|c| cats.iter().find(|x| x.id == c)) else { + report.errors.push((i, "Kategori seçin".into())); + continue; + }; + let Some(group) = cat.group else { + report.errors.push((i, "Kategorinin grubu yok".into())); + continue; + }; + let amount = if kind == Kind::Refund { + -r.amount + } else { + r.amount + }; + let mut d = new_meta(household_id); + d.insert("period_id", pid); + d.insert("date", &r.date); + d.insert("amount", amount); + d.insert("group", group.as_str()); + d.insert("category_id", cat.id); + d.insert( + "payment_method_id", + payment_method_id.map_or(Bson::Null, Bson::from), + ); + d.insert( + "description", + if desc.is_empty() { + Bson::Null + } else { + Bson::String(desc) + }, + ); + d.insert("created_by", user_id); + d.insert("import_key", &key); + entries.push(d); + if kind == Kind::Refund { + report.refunds += 1; + } else { + report.entries += 1; + } + if !pattern.is_empty() { + learned.insert(pattern, (kind, Some(cat.id))); + } + } + Kind::Income => { + // Plan kalemi adı en fazla 60 karakter + let name: String = if desc.is_empty() { + "Gelir".into() + } else { + desc.chars().take(60).collect() + }; + incomes.push(( + i, + pid, + NewItem { + kind: PlanKind::Income, + name, + amount: r.amount, + date: Some(r.date.clone()), + }, + key, + )); + if !pattern.is_empty() { + learned.insert(pattern, (kind, None)); + } + } + Kind::Ignore => unreachable!(), + } + } + + if !entries.is_empty() { + db.collection::(col::ENTRIES) + .insert_many(entries) + .await?; + } + for (i, pid, item, key) in incomes { + match plan::create(db, household_id, pid, item).await { + Ok(p) => { + db.collection::(col::PLAN_ITEMS) + .update_one(doc! { "_id": p.id }, doc! { "$set": { "import_key": key } }) + .await?; + report.incomes += 1; + } + Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => report.errors.push((i, m)), + Err(e) => return Err(e), + } + } + + // Kurallar: bu aktarımdaki seçimler sonraki ekstreler için öneri olur + let ts = now(); + for (pattern, (kind, category_id)) in learned { + let mut f = scope(household_id); + f.insert("pattern", &pattern); + db.collection::(col::IMPORT_RULES) + .update_one( + f, + doc! { + "$set": { + "kind": kind.as_str(), + "category_id": category_id.map_or(Bson::Null, Bson::from), + "updated_at": ts, + }, + "$inc": { "uses": 1, "version": 1 }, + "$setOnInsert": { "_id": crate::models::common::new_id(), "created_at": ts }, + }, + ) + .upsert(true) + .await?; + } + report.errors.sort_by_key(|(i, _)| *i); + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folding_and_keys() { + assert_eq!(fold("ÖDEME Şükrü İĞNE"), "odeme sukru igne"); + assert_eq!(rule_key("MIGROS 1234 ISTANBUL TR"), "migros istanbul"); + assert_eq!(rule_key("A101 MAGAZACILIK*456"), "a101 magazacilik"); + assert_eq!(rule_key("12/11 *"), ""); + assert_eq!( + import_key("2026-11-13", Direction::Out, 4590, " Migros ŞUBE 12 "), + "2026-11-13|out|4590|migros sube" + ); + } + + #[test] + fn default_kinds() { + use Direction::*; + use Source::*; + assert_eq!(default_kind(Card, Out, "MIGROS ISTANBUL"), Kind::Expense); + assert_eq!(default_kind(Card, In, "IADE MIGROS"), Kind::Refund); + assert_eq!( + default_kind(Card, In, "ÖDEMENİZ İÇİN TEŞEKKÜRLER"), + Kind::Ignore + ); + assert_eq!(default_kind(Account, In, "MAAŞ ÖDEMESİ ACME"), Kind::Income); + assert_eq!( + default_kind(Account, Out, "KREDİ KARTI ÖDEMESİ"), + Kind::Ignore + ); + assert_eq!( + default_kind(Account, Out, "Hesaplar arası virman"), + Kind::Ignore + ); + assert_eq!( + default_kind(Account, Out, "ELEKTRIK FATURASI"), + Kind::Expense + ); + } +} diff --git a/backend/src/services/budget.rs b/backend/src/services/budget.rs index c6cf8be..55dbc3b 100644 --- a/backend/src/services/budget.rs +++ b/backend/src/services/budget.rs @@ -553,7 +553,8 @@ async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult AppResult> { let mut f = scope(household_id); f.insert("start_date", doc! { "$lt": &p.start_date }); - f.insert("status", PeriodStatus::Closed.as_str()); + f.insert( + "status", + doc! { "$in": [PeriodStatus::Closed.as_str(), PeriodStatus::Archived.as_str()] }, + ); let mut cursor = db .collection::(col::PERIODS) .find(f) diff --git a/backend/src/services/closing.rs b/backend/src/services/closing.rs index 6b44ee1..9eb301e 100644 --- a/backend/src/services/closing.rs +++ b/backend/src/services/closing.rs @@ -90,6 +90,11 @@ pub async fn close( version: Option, ) -> AppResult { let p = period::get(db, household_id, period_id).await?; + if p.status == PeriodStatus::Archived { + return Err(AppError::Conflict( + "Arşiv dönemi kapatılmaz; yalnızca kayıt tutar".into(), + )); + } if p.status == PeriodStatus::Closed { return Err(AppError::Conflict("Bu dönem zaten kapalı".into())); } @@ -200,7 +205,11 @@ pub async fn reopen( } let mut later = scope(household_id); later.insert("start_date", doc! { "$gte": &p.end_date }); - later.insert("status", doc! { "$ne": PeriodStatus::Open.as_str() }); + // Arşiv dönemleri devir üretmediği için engel değildir + later.insert( + "status", + doc! { "$nin": [PeriodStatus::Open.as_str(), PeriodStatus::Archived.as_str()] }, + ); if db .collection::(col::PERIODS) .find_one(later) @@ -242,6 +251,8 @@ pub async fn reopen( async fn next_period(db: &Database, household_id: Uuid, p: &Period) -> AppResult> { let mut f = scope(household_id); f.insert("start_date", doc! { "$gte": &p.end_date }); + // Devir arşiv dönemine bağlanmaz (arşiv devir zincirinde yok) + f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() }); Ok(db .collection::(col::PERIODS) .find_one(f) diff --git a/backend/src/services/entry.rs b/backend/src/services/entry.rs index 2b17317..7d07fd5 100644 --- a/backend/src/services/entry.rs +++ b/backend/src/services/entry.rs @@ -1,7 +1,8 @@ //! Günlük defter: harcama kayıtları (KAKEIBO_PROMPT §8.4). //! //! Kurallar: -//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı. +//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı. İstisna: ekstreden gelen **iade** +//! kayıtları eksi tutarlıdır (kategorinin harcamasından düşer); düzenlemede eksi kalır. //! - Grup **kategoriden** alınır (istemci grup göndermez) → kategori ile grup hiç çelişmez. //! - Kategori ve ödeme türü bu haneye ait olmalı; yeni seçimde **aktif** olmalı //! (pasife alınmış kategoride kalan eski kayıt düzenlenebilir). @@ -146,7 +147,16 @@ pub async fn update( set.insert("date", clean_date(&date, &p)?); } if let Some(amount) = ch.amount { - set.insert("amount", clean_amount(amount)?); + // İade kaydı (ekstreden, eksi tutar) iade olarak kalır: tutar eksi girilir + let amount = if current.amount < 0 { + if amount >= 0 { + return Err(AppError::BadRequest("İade tutarı eksi olmalı".into())); + } + -clean_amount(-amount)? + } else { + clean_amount(amount)? + }; + set.insert("amount", amount); } if let Some(cid) = ch.category_id { let group = category_group(db, household_id, cid, Some(current.category_id)).await?; diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 09f8028..0aa04f9 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod account; pub mod asset; +pub mod bank_import; pub mod budget; pub mod calendar; pub mod catalog; diff --git a/backend/src/services/period.rs b/backend/src/services/period.rs index 0500299..2039990 100644 --- a/backend/src/services/period.rs +++ b/backend/src/services/period.rs @@ -110,6 +110,8 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul // Bir önceki dönem (devir ve "önceki aydan kopyala" için) let mut prev_f = scope(household_id); prev_f.insert("start_date", doc! { "$lt": format_date(start) }); + // Arşiv dönemleri devir zincirinde yok + prev_f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() }); let previous = coll(db) .find_one(prev_f) .sort(doc! { "start_date": -1 }) @@ -273,6 +275,42 @@ fn clean_name(name: Option<&str>, start: Date, end: Date) -> AppResult { /// Başka bir dönemle çakışma: `mevcut.başlangıç < yeni.bitiş && mevcut.bitiş > yeni.başlangıç` /// (`YYYY-MM-DD` metinleri sözlük sırasıyla karşılaştırılabilir). +/// Geçmiş (arşiv) dönem açar: ekstre içe aktarımında dönemi olmayan aylar için. +/// Açık dönem kuralına takılmaz (arşiv açık sayılmaz); çakışma yine yasak. Plan, bütçe, devir yok. +pub async fn create_archive( + db: &Database, + household_id: Uuid, + start: Date, + end: Date, +) -> AppResult { + if end <= start { + return Err(AppError::BadRequest( + "Arşiv dönemi tarihleri geçersiz".into(), + )); + } + ensure_no_overlap(db, household_id, start, end, None).await?; + let mut d = new_meta(household_id); + let id = d.get("_id").cloned().unwrap_or(Bson::Null); + d.insert("name", clean_name(None, start, end)?); + d.insert("start_date", format_date(start)); + d.insert("end_date", format_date(end)); + d.insert("status", PeriodStatus::Archived.as_str()); + d.insert("savings_goal", 0_i64); + d.insert("reflection_md", Bson::Null); + d.insert("to_savings", Bson::Null); + d.insert("previous_period_id", Bson::Null); + d.insert("closed_at", Bson::Null); + d.insert("closed_by", Bson::Null); + d.insert("reopened_at", Bson::Null); + db.collection::(col::PERIODS) + .insert_one(d) + .await?; + coll(db) + .find_one(doc! { "_id": id }) + .await? + .ok_or_else(|| AppError::Internal("arşiv dönemi okunamadı".into())) +} + async fn ensure_no_overlap( db: &Database, household_id: Uuid, diff --git a/backend/tests/bank_import.rs b/backend/tests/bank_import.rs new file mode 100644 index 0000000..f339da7 --- /dev/null +++ b/backend/tests/bank_import.rs @@ -0,0 +1,376 @@ +//! Ekstre içe aktarma: öneri (tür/kategori/kural), arşiv dönemi, iade, gelir, yoksay, +//! tekrar tespiti, kapalı/gelecek dönem engeli, arşiv kuralları. + +mod common; + +use axum::http::StatusCode; +use common::{Session, TestApp}; +use serde_json::{Value, json}; + +async fn id_by(app: &TestApp, s: &Session, path: &str, name: &str) -> String { + let (_, list) = app.get(path, s).await; + list.as_array() + .unwrap() + .iter() + .find(|c| c["name"] == name) + .unwrap()["id"] + .as_str() + .unwrap() + .to_string() +} + +fn row(date: &str, desc: &str, amount: i64, dir: &str) -> Value { + json!({ "date": date, "description": desc, "amount": amount, "direction": dir }) +} + +async fn preview(app: &TestApp, s: &Session, source: &str, rows: &[Value]) -> Value { + let (st, b) = app + .post( + "/bank-import/preview", + s, + json!({ "source": source, "rows": rows }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{b}"); + b +} + +async fn commit(app: &TestApp, s: &Session, pm: Option<&str>, rows: Vec) -> Value { + let (st, b) = app + .post( + "/bank-import/commit", + s, + json!({ "payment_method_id": pm, "rows": rows }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{b}"); + b +} + +fn with(mut r: Value, kind: &str, cat: Option<&str>) -> Value { + r["kind"] = json!(kind); + if let Some(c) = cat { + r["category_id"] = json!(c); + } + r +} + +#[tokio::test] +async fn card_statement_with_archive_refund_and_rules() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let market = id_by(&app, &s, "/categories", "Market").await; + let kart = id_by(&app, &s, "/payment-methods", "Kredi Kartı").await; + // Ekim kapalı, Kasım açık + let (_, oct) = app + .post( + "/periods", + &s, + json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }), + ) + .await; + let oct = oct["id"].as_str().unwrap().to_string(); + app.post(&format!("/periods/{oct}/close"), &s, json!({})) + .await; + let (_, nov) = app + .post( + "/periods", + &s, + json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }), + ) + .await; + let nov = nov["id"].as_str().unwrap().to_string(); + + let rows = vec![ + row("2026-09-10", "MİGROS 1234 İSTANBUL TR", 45_000, "out"), + row("2026-09-12", "IADE MIGROS ISTANBUL", 10_000, "in"), + row("2026-09-30", "ÖDEMENİZ İÇİN TEŞEKKÜRLER", 500_000, "in"), + row("2026-11-05", "A101 MAGAZACILIK", 20_000, "out"), + row("2099-12-20", "GELECEK", 1_000, "out"), + row("2026-10-05", "EKIM", 1_000, "out"), + ]; + let p = preview(&app, &s, "card", &rows).await; + let kinds: Vec<&str> = p["rows"] + .as_array() + .unwrap() + .iter() + .map(|r| r["kind"].as_str().unwrap()) + .collect(); + assert_eq!( + kinds, + [ + "expense", "refund", "ignore", "expense", "expense", "expense" + ] + ); + assert_eq!(p["rows"][0]["period"], "archive"); + assert_eq!(p["rows"][0]["period_name"], "Eylül 2026"); + assert_eq!(p["rows"][3]["period"], "existing"); + assert_eq!(p["rows"][3]["period_name"], "Kasım 2026"); + assert_eq!(p["rows"][4]["message"], "Gelecek tarihli satır"); + assert_eq!( + p["rows"][5]["message"], + "Dönem kapalı (Ekim 2026); düzeltme için yeniden açın" + ); + assert_eq!( + p["archives"], + json!([{ "name": "Eylül 2026", "start_date": "2026-09-01", "last_date": "2026-09-30" }]) + ); + assert_eq!(p["rows"][0]["from_rule"], false); + + // Aktar: kullanıcı seçimleri + let chosen = vec![ + with(rows[0].clone(), "expense", Some(&market)), + with(rows[1].clone(), "refund", Some(&market)), + with(rows[2].clone(), "ignore", None), + with(rows[3].clone(), "expense", Some(&market)), + with(rows[4].clone(), "expense", Some(&market)), + with(rows[5].clone(), "expense", Some(&market)), + ]; + let r = commit(&app, &s, Some(&kart), chosen.clone()).await; + assert_eq!( + ( + r["entries"].as_u64(), + r["refunds"].as_u64(), + r["ignored"].as_u64() + ), + (Some(2), Some(1), Some(1)) + ); + assert_eq!(r["archives"], json!(["Eylül 2026"])); + assert_eq!( + r["errors"] + .as_array() + .unwrap() + .iter() + .map(|e| e["index"].as_u64().unwrap()) + .collect::>(), + [4, 5] + ); + + // Arşiv dönemi: Eylül, net harcama 450 − 100 = 350 + let (_, periods) = app.get("/periods", &s).await; + let sep = periods + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == "Eylül 2026") + .unwrap() + .clone(); + assert_eq!(sep["status"], "archived"); + let sep_id = sep["id"].as_str().unwrap(); + let (_, sum) = app.get(&format!("/periods/{sep_id}/summary"), &s).await; + assert_eq!(sum["spent_total"], 35_000, "iade harcamadan düşer"); + let (_, entries) = app.get(&format!("/periods/{sep_id}/entries"), &s).await; + let refund = entries + .as_array() + .unwrap() + .iter() + .find(|e| e["amount"] == -10_000) + .unwrap() + .clone(); + assert_eq!(refund["payment_method_id"], kart.as_str()); + assert_eq!(refund["description"], "IADE MIGROS ISTANBUL"); + let (_, nov_entries) = app.get(&format!("/periods/{nov}/entries"), &s).await; + assert_eq!(nov_entries.as_array().unwrap().len(), 1); + + // Aynı ekstre tekrar: öğrenilen kural + tekrar işareti; aktarma çift kayıt yapmaz + let p = preview(&app, &s, "card", &rows).await; + assert_eq!(p["rows"][0]["from_rule"], true); + assert_eq!(p["rows"][0]["category_id"], market.as_str()); + assert_eq!(p["rows"][1]["kind"], "refund"); + assert_eq!(p["rows"][2]["kind"], "ignore", "yoksay kuralı da öğrenildi"); + assert_eq!( + p["rows"] + .as_array() + .unwrap() + .iter() + .map(|r| r["duplicate"].as_bool().unwrap()) + .collect::>(), + [true, true, false, true, false, false] + ); + assert_eq!(p["archives"], json!([]), "Eylül artık var"); + let r = commit(&app, &s, Some(&kart), chosen).await; + assert_eq!( + (r["entries"].as_u64(), r["duplicates"].as_u64()), + (Some(0), Some(3)) + ); + + // İade düzenlemede eksi kalır + let rid = refund["id"].as_str().unwrap(); + let (st, _) = app + .patch(&format!("/entries/{rid}"), &s, json!({ "amount": 5_000 })) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + let (st, e) = app + .patch(&format!("/entries/{rid}"), &s, json!({ "amount": -5_000 })) + .await; + assert_eq!(st, StatusCode::OK, "{e}"); + + // Arşiv kapatılmaz; öneriler arşivi kullanır (Ekim 0 + Eylül 400 net) / 2 dönem + let (st, b) = app + .post(&format!("/periods/{sep_id}/close"), &s, json!({})) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{b}"); + let (_, sug) = app + .get(&format!("/periods/{nov}/budget/suggestion?method=avg3"), &s) + .await; + assert_eq!(sug["available"], true, "{sug}"); + assert_eq!(sug["amounts"]["needs"], 20_000, "(450 − 50) / 2"); + + // Başka hane kuralları görmez + let other = app.register("Diger", None).await; + let p = preview(&app, &other, "card", &rows[..1]).await; + assert_eq!(p["rows"][0]["from_rule"], false); + assert_eq!(p["rows"][0]["duplicate"], false); + app.cleanup().await; +} + +#[tokio::test] +async fn account_statement_income_and_transfers() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let fatura = id_by(&app, &s, "/categories", "Faturalar").await; + let rows = vec![ + row("2026-08-01", "MAAŞ ÖDEMESİ ACME A.Ş.", 4_500_000, "in"), + row("2026-08-05", "KREDİ KARTI ÖDEMESİ", 1_200_000, "out"), + row("2026-08-07", "ELEKTRIK FATURASI", 85_000, "out"), + row("2026-08-31", "HESAPLAR ARASI VİRMAN", 100_000, "out"), + ]; + let p = preview(&app, &s, "account", &rows).await; + let kinds: Vec<&str> = p["rows"] + .as_array() + .unwrap() + .iter() + .map(|r| r["kind"].as_str().unwrap()) + .collect(); + assert_eq!(kinds, ["income", "ignore", "expense", "ignore"]); + + let r = commit( + &app, + &s, + None, + vec![ + with(rows[0].clone(), "income", None), + with(rows[1].clone(), "ignore", None), + with(rows[2].clone(), "expense", Some(&fatura)), + with(rows[3].clone(), "ignore", None), + ], + ) + .await; + assert_eq!( + ( + r["incomes"].as_u64(), + r["entries"].as_u64(), + r["ignored"].as_u64() + ), + (Some(1), Some(1), Some(2)) + ); + let (_, periods) = app.get("/periods", &s).await; + let aug = periods + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == "Ağustos 2026") + .unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let (_, sum) = app.get(&format!("/periods/{aug}/summary"), &s).await; + assert_eq!(sum["income_total"], 4_500_000); + assert_eq!(sum["spent_total"], 85_000); + let (_, items) = app.get(&format!("/periods/{aug}/plan-items"), &s).await; + assert_eq!(items[0]["name"], "MAAŞ ÖDEMESİ ACME A.Ş."); + assert_eq!(items[0]["date"], "2026-08-01"); + // Gelir de tekrar etmez + let p = preview(&app, &s, "account", &rows[..1]).await; + assert_eq!(p["rows"][0]["duplicate"], true); + + // Kategori seçilmemiş harcama → satır hatası; boş istek → 400 + let r = commit( + &app, + &s, + None, + vec![with(row("2026-08-09", "SU", 1_000, "out"), "expense", None)], + ) + .await; + assert_eq!(r["errors"][0]["message"], "Kategori seçin"); + let (st, _) = app + .post( + "/bank-import/preview", + &s, + json!({ "source": "card", "rows": [] }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + app.cleanup().await; +} + +#[tokio::test] +async fn archives_are_skipped_in_the_carryover_chain() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let market = id_by(&app, &s, "/categories", "Market").await; + // Ekim: gelir 10.000, 5.000 birikime → 5.000 artı devreder (bağlanacak dönem yok) + let (_, oct) = app + .post( + "/periods", + &s, + json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }), + ) + .await; + let oct = oct["id"].as_str().unwrap().to_string(); + app.post( + &format!("/periods/{oct}/plan-items"), + &s, + json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }), + ) + .await; + app.post( + &format!("/periods/{oct}/close"), + &s, + json!({ "to_savings": 500_000 }), + ) + .await; + + // Kasım ekstresi → Kasım arşiv dönemi + let r = commit( + &app, + &s, + None, + vec![with( + row("2026-11-03", "MIGROS", 10_000, "out"), + "expense", + Some(&market), + )], + ) + .await; + assert_eq!(r["archives"], json!(["Kasım 2026"])); + + // Aralık açılır: önceki (devir) dönemi arşiv değil Ekim → artı Aralık'a bağlanır + let (st, dec) = app + .post( + "/periods", + &s, + json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{dec}"); + let dec = dec["id"].as_str().unwrap().to_string(); + let (_, sum) = app.get(&format!("/periods/{dec}/summary"), &s).await; + assert_eq!(sum["carryover_surplus"], 500_000); + + // Ekim yeniden açılıp kapatılırsa yeni devir yine Aralık'a (arşiv Kasım değil) + app.post(&format!("/periods/{oct}/reopen"), &s, json!({})) + .await; + let (st, b) = app + .post( + &format!("/periods/{oct}/close"), + &s, + json!({ "to_savings": 400_000 }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{b}"); + assert_eq!(b["carryover"]["to_period_id"], dec.as_str()); + let (_, sum) = app.get(&format!("/periods/{dec}/summary"), &s).await; + assert_eq!(sum["carryover_surplus"], 600_000); + app.cleanup().await; +} diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index a6fdfab..5ec334c 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -81,7 +81,7 @@ Renkler ve ikonlar frontend tasarım token'larında. ```js { _id, household_id, name /* "Aralık 2026" */, start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ - status: "open" | "closed" | "correcting", + status: "open" | "closed" | "correcting" | "archived", savings_goal: 500000, // kuruş — birikim hedefi reflection_md: "...", // "Nasıl daha iyi yapabilirim?" to_savings: 450000, // kapanışta birikime aktarılan (Faz 8'de varlık hareketi) @@ -93,6 +93,7 @@ Renkler ve ikonlar frontend tasarım token'larında. - Hanede en fazla **bir** `open` dönem (servis kontrolü + kısmi benzersiz indeks). - **Yeniden açma (karar 2026-11-12, "düzeltme modu"):** sahip kapalı dönemi açınca durum `correcting` olur; yazılabilir ama "açık dönem" sayılmaz → yeni dönem açık kalabilir (tek `open` kuralı korunur). Mevcut devir "yeniden kapat"a kadar yerinde kalır; yeniden kapatınca eskisi soft delete, yenisi hesaplanır. Ardından kapanmış dönem varsa açılamaz (zincirleme devirler bozulmasın). - **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (Pazartesi başlangıçlı; ilk/son kısmi haftalar ayrı). +- **Arşiv (`archived`, karar 2026-11-13):** ekstre içe aktarımında dönemi olmayan aylar için takvim ayı kadar açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Yazılabilir (kayıt düzeltme), ama kapatılmaz, devir üretmez; devir zincirinde (yeni dönemin önceki dönemi, kapatmada sonraki dönem, yeniden açma engeli) **atlanır**. Bütçe önerileri ("önceki dönem", "son 3 ay") kapalı **ve** arşiv dönemleri kullanır. ### 3.5 `categories` — Alt kategoriler ```js @@ -137,6 +138,15 @@ Renkler ve ikonlar frontend tasarım token'larında. description: "Haftalık market", created_by /* user_id */, created_at, ... } ``` +- **İade** (ekstreden, karar 2026-11-13): aynı kategoride **eksi** `amount`; toplamlar net olur. Elle girişte tutar > 0; iade düzenlemede eksi kalır. +- `import_key` (isteğe bağlı): ekstreden gelen kayıtta `tarih|yön|tutar|sadeleştirilmiş açıklama` → aynı satır ikinci kez aktarılmaz (plan kalemlerinde de, gelir için). + +### 3.9a `import_rules` — Ekstre öğrenilmiş kuralları +```js +{ _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. ### 3.10 `carryovers` — Devirler ```js diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 96bb8cf..2fd7b3f 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,10 +1,11 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 9 – Adım 4:** Banka / kredi kartı ekstresi içe aktarma (kullanıcı isteği; PHASES Faz 9 Adım 4). Önce tasarımı kullanıcıya sun: biçimler (CSV / Excel .xlsx — Excel için bağımlılık gerekir, gerekçe), sütun eşleştirme + banka profili (kaydedilir), yön (kart: harcama +; hesap: çıkış −, giriş = gelir?), taksit/iade, tekrar tespiti (tarih+tutar+açıklama), açıklamadan kategori önerisi (öğrenen kural, hane bazlı), **geçmiş (arşiv) dönemler** (yıl başından; plan yok → kapatma/devir zincirine etkisi; durum? `archived`?), aktarım `transfer.rs` + `csv_io.rs` üstüne. Karar gerektirenleri sor. +**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). **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 +- **Ekstre kararları (kullanıcı, 2026-11-13):** biçim Excel (.xlsx/.xls) + CSV (kullanıcı tam bilmiyor; ilk gerçek ekstreyle denenecek), hesaba gelenler → **gelir** (plan kalemi), arşiv dönemi **takvim ayı**, iadeler **aynı kategoriden düşülür** (eksi tutarlı kayıt). - **Dikkat (2026-11-13):** Claude'un hızlı ardışık `backend/src` değişiklikleri (bilerek bozup geri alma, toplu düzenleme) kullanıcının `npm run dev` içindeki `cargo watch`'ını backend kapalı halde bırakabiliyor (giriş: "Beklenmeyen hata", 5173 → 502). Böyle denemelerden sonra `curl localhost:8080/api/health`; kapalıysa `touch backend/src/main.rs` (cargo watch yeniden başlatır). Arayüz artık 502/503/504'te "Sunucu şu an yanıt vermiyor" der. - **Faz 9 kararları (kullanıcı, 2026-11-13):** (1) Aylık rapor biçimini **Claude tasarlar**: müşteri gözüyle ne yaptım / ne yapılabilirdi / ne yapılmalıydı. (2) JSON yedek: Claude best practice analizi yapıp kullanıcıyı üzmeyen çözüm üretir (öneri: sunucuda otomatik geri dönüş noktası + "geri al", önizleme, sahip). (3) **Tüm kredi kartı ve nakit/banka hesabı ekstreleri** içe aktarılabilsin; amaç **yıl başından beri** veriyi toplayıp sonraki aylara referans; grup/kategori eşleştirmesi kullanıcıya bırakılabilir → ayrı adım (Faz 9 Adım 4), geçmiş dönemler gerekir. - **Yeniden kapatmada birikim (kullanıcı "a", 2026-11-13):** bağlı TL alımı yeni tutara güncellenir (0 → silinir). @@ -137,3 +138,4 @@ - **Adım 1:** Rapor verileri. `/stats/periods` → `PeriodStat` artık `summary: budget::Summary` taşır; DTO'ya `income_total`, `fixed_expense_total`, `savings_goal`, `carryover_debt/surplus`, `realized_savings` (= review 4. soru) eklendi. `services/reports.rs` + `GET /reports/breakdown` (tek `$facet`: toplam, 4 grup sabit sırada (0 dahil), **tüm** kategoriler çoktan aza, ödeme türü (`$ifNull` → `null` = girilmemiş), ay `$substrBytes`; filtre dönem/tarih aralığı (dahil, başlangıç>bitiş 400)/grup/kategori/ödeme türü (`none`)). Bütçe sapması için yeni uç nokta yok (mevcut `/budget`). `tests/reports.rs` (1, kapsamlı) + stats testi genişledi. Mutasyon 2/2. Backend 155 test. - **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. diff --git a/docs/PHASES.md b/docs/PHASES.md index 1dcfeb8..a319364 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** (kullanıcı isteği): CSV/Excel, sütun eşleştirme (banka profili kaydedilir), harcama/gelir yönü, tekrar eden satır tespiti, açıklamadan kategori önerisi (öğrenen kural), grup/kategori eşleştirmesi kullanıcıda; eksik aylar için **geçmiş (arşiv) dönemler** — amaç yıl başından beri veri → sonraki aylara referans (ortalama önerileri) +- 🔄 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) - ⏳ 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/src/features/planning/plan-read.tsx b/frontend/src/features/planning/plan-read.tsx index 33651f9..6ca9cbf 100644 --- a/frontend/src/features/planning/plan-read.tsx +++ b/frontend/src/features/planning/plan-read.tsx @@ -24,7 +24,7 @@ export function PeriodHeading({ period, actions }: { period: Period; actions?: R {t.periods.status[period.status]} diff --git a/frontend/src/features/review/close-page.tsx b/frontend/src/features/review/close-page.tsx index edcdc47..cafb1c7 100644 --- a/frontend/src/features/review/close-page.tsx +++ b/frontend/src/features/review/close-page.tsx @@ -52,8 +52,10 @@ export function ClosePeriodPage() { // Açık dönem ya da `?donem=` ile düzeltmedeki dönem (yeniden kapatma) {(p) => - p.status === 'closed' ? ( -

{s.alreadyClosed}

+ p.status === 'closed' || p.status === 'archived' ? ( +

+ {p.status === 'closed' ? s.alreadyClosed : s.archived} +

) : ( ) diff --git a/frontend/src/features/review/period-status.tsx b/frontend/src/features/review/period-status.tsx index 1e79559..5d9c37c 100644 --- a/frontend/src/features/review/period-status.tsx +++ b/frontend/src/features/review/period-status.tsx @@ -20,6 +20,14 @@ const s = t.review */ export function ClosingSection({ period, periods }: { period: Period; periods: Period[] }) { if (period.status === 'closed') return + if (period.status === 'archived') { + return ( +
+

{s.archivedTitle}

+

{s.archivedHint}

+
+ ) + } const correcting = period.status === 'correcting' return (
`Birikime ${x}`, @@ -655,6 +663,7 @@ export const t = { toDashboard: 'Panele dön', noOpen: 'Kapatılacak açık dönem yok.', alreadyClosed: 'Bu dönem zaten kapalı.', + archived: 'Arşiv dönemi kapatılmaz; yalnızca geçmiş kayıtları tutar.', addToSavings: (x: string) => `🐷 Birikime ekle (${x})`, addToSavingsHint: 'Onaylarsan bu tutar Birikimler’de, dönemin son günü tarihli Türk Lirası alımı olarak görünür.', diff --git a/frontend/src/types/BankArchive.ts b/frontend/src/types/BankArchive.ts new file mode 100644 index 0000000..1eeabc2 --- /dev/null +++ b/frontend/src/types/BankArchive.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type BankArchive = { name: string, start_date: string, +/** + * Son gün, dahil + */ +last_date: string, }; diff --git a/frontend/src/types/BankCommitRequest.ts b/frontend/src/types/BankCommitRequest.ts new file mode 100644 index 0000000..c01da36 --- /dev/null +++ b/frontend/src/types/BankCommitRequest.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BankRowIn } from "./BankRowIn"; + +export type BankCommitRequest = { +/** + * Kayıtlara yazılacak ödeme türü (ör. Kredi Kartı) + */ +payment_method_id?: string, rows: Array, }; diff --git a/frontend/src/types/BankImportReport.ts b/frontend/src/types/BankImportReport.ts new file mode 100644 index 0000000..cb57477 --- /dev/null +++ b/frontend/src/types/BankImportReport.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BankRowError } from "./BankRowError"; + +export type BankImportReport = { entries: number, refunds: number, incomes: number, ignored: number, duplicates: number, +/** + * Açılan arşiv dönemleri + */ +archives: Array, errors: Array, }; diff --git a/frontend/src/types/BankPreview.ts b/frontend/src/types/BankPreview.ts new file mode 100644 index 0000000..e1cc5de --- /dev/null +++ b/frontend/src/types/BankPreview.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BankArchive } from "./BankArchive"; +import type { BankPreviewRow } from "./BankPreviewRow"; + +export type BankPreview = { rows: Array, +/** + * Açılacak arşiv dönemleri + */ +archives: Array, }; diff --git a/frontend/src/types/BankPreviewRequest.ts b/frontend/src/types/BankPreviewRequest.ts new file mode 100644 index 0000000..023c628 --- /dev/null +++ b/frontend/src/types/BankPreviewRequest.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BankRowIn } from "./BankRowIn"; + +export type BankPreviewRequest = { source: "card" | "account", rows: Array, }; diff --git a/frontend/src/types/BankPreviewRow.ts b/frontend/src/types/BankPreviewRow.ts new file mode 100644 index 0000000..666fe62 --- /dev/null +++ b/frontend/src/types/BankPreviewRow.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type BankPreviewRow = { kind: "expense" | "refund" | "income" | "ignore", category_id: string | null, +/** + * Öneri hanenin öğrendiği kuraldan + */ +from_rule: boolean, +/** + * Daha önce aktarılmış (atlanacak) + */ +duplicate: boolean, +/** + * `existing` · `archive` · `blocked` + */ +period: "existing" | "archive" | "blocked", +/** + * Dönem adı (var olan ya da açılacak arşiv) + */ +period_name: string | null, +/** + * Engel nedeni + */ +message: string | null, }; diff --git a/frontend/src/types/BankRowError.ts b/frontend/src/types/BankRowError.ts new file mode 100644 index 0000000..cd514c9 --- /dev/null +++ b/frontend/src/types/BankRowError.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type BankRowError = { +/** + * Satır sırası (0'dan) + */ +index: number, message: string, }; diff --git a/frontend/src/types/BankRowIn.ts b/frontend/src/types/BankRowIn.ts new file mode 100644 index 0000000..e800ec5 --- /dev/null +++ b/frontend/src/types/BankRowIn.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type BankRowIn = { +/** + * `"YYYY-MM-DD"` + */ +date: string, description: string, +/** + * Kuruş, > 0 + */ +amount: number, direction: "in" | "out", +/** + * Yalnızca aktarmada (kullanıcının seçimi) + */ +kind?: "expense" | "refund" | "income" | "ignore", category_id?: string, }; diff --git a/frontend/src/types/PeriodStatus.ts b/frontend/src/types/PeriodStatus.ts index c4e4b09..7530064 100644 --- a/frontend/src/types/PeriodStatus.ts +++ b/frontend/src/types/PeriodStatus.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PeriodStatus = "open" | "closed" | "correcting"; +export type PeriodStatus = "open" | "closed" | "correcting" | "archived";