feat(ekstre): banka/kart ekstresi içe aktarma altyapısı (arşiv dönemleri, iade, öğrenen kurallar)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 08:33:07 +03:00
parent 4d764689e2
commit d1a24afc3c
28 changed files with 1422 additions and 13 deletions
+6
View File
@@ -69,6 +69,8 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
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<IndexModel>)> {
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 })],
+3
View File
@@ -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,
];
}
+5
View File
@@ -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",
}
}
+221
View File
@@ -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<Kind>,
#[ts(optional)]
category_id: Option<String>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "BankPreviewRequest")]
pub struct PreviewRequest {
#[ts(type = "\"card\" | \"account\"")]
source: Source,
rows: Vec<RowIn>,
}
#[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<String>,
rows: Vec<RowIn>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BankPreviewRow")]
struct PreviewRowDto {
#[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"")]
kind: &'static str,
category_id: Option<String>,
/// Ö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<String>,
/// Engel nedeni
message: Option<String>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BankPreview")]
pub struct PreviewDto {
rows: Vec<PreviewRowDto>,
/// Açılacak arşiv dönemleri
archives: Vec<ArchiveDto>,
}
#[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<String>,
errors: Vec<RowError>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BankRowError")]
struct RowError {
/// Satır sırası (0'dan)
index: u32,
message: String,
}
fn rows_of(rows: Vec<RowIn>) -> AppResult<Vec<Row>> {
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<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<PreviewRequest>,
) -> AppResult<Json<PreviewDto>> {
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<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<CommitRequest>,
) -> AppResult<Json<ReportDto>> {
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(),
}))
}
+9
View File
@@ -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
+613
View File
@@ -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<Kind>,
pub category_id: Option<Uuid>,
}
// ---------- 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<String> {
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::<Vec<_>>()
.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<Uuid>,
}
async fn rules(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, Rule>> {
let mut c = db
.collection::<Rule>(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<HashMap<String, usize>> {
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::<Document>(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<String, usize>, 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<Uuid>,
/// Öneri hanenin öğrenilmiş kuralından mı?
pub from_rule: bool,
pub duplicate: bool,
pub target: Target,
}
pub struct Preview {
pub rows: Vec<PreviewRow>,
/// 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<Preview> {
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<String>,
/// (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<Uuid>,
rows: &[Row],
) -> AppResult<Report> {
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<Document> = Vec::new();
let mut incomes: Vec<(usize, Uuid, NewItem, String)> = Vec::new();
let mut learned: HashMap<String, (Kind, Option<Uuid>)> = 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::<Document>(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::<Document>(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::<Document>(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
);
}
}
+6 -2
View File
@@ -553,7 +553,8 @@ async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u
.collect())
}
/// Bu dönemden önceki en yeni `n` kapalı dönemin kimlikleri.
/// Bu dönemden önceki en yeni `n` kapalı **ya da arşiv** dönemin kimlikleri
/// (arşiv: ekstreden aktarılan geçmiş aylar → öneriler gerçek harcamaya dayanır).
async fn previous_closed(
db: &Database,
household_id: Uuid,
@@ -562,7 +563,10 @@ async fn previous_closed(
) -> AppResult<Vec<Uuid>> {
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::<Period>(col::PERIODS)
.find(f)
+12 -1
View File
@@ -90,6 +90,11 @@ pub async fn close(
version: Option<i32>,
) -> AppResult<Closing> {
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::<Document>(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<Option<Period>> {
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::<Period>(col::PERIODS)
.find_one(f)
+12 -2
View File
@@ -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?;
+1
View File
@@ -2,6 +2,7 @@
pub mod account;
pub mod asset;
pub mod bank_import;
pub mod budget;
pub mod calendar;
pub mod catalog;
+38
View File
@@ -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<String> {
/// 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<Period> {
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::<Document>(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,
+376
View File
@@ -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>) -> 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::<Vec<_>>(),
[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::<Vec<_>>(),
[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;
}