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:
@@ -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 })],
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod account;
|
||||
pub mod asset;
|
||||
pub mod bank_import;
|
||||
pub mod budget;
|
||||
pub mod calendar;
|
||||
pub mod catalog;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user