Compare commits

..

6 Commits

Author SHA1 Message Date
suphicakir d1a24afc3c 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>
2026-11-13 08:33:07 +03:00
suphicakir 4d764689e2 feat(veri): CSV dışa ve içe aktarma (harcamalar, plan kalemleri, varlık işlemleri)
- Excel uyumlu CSV indirme
- İçe aktarma: önce deneme raporu (geçerli / zaten var / satır satır hata), onayla geçerli satırlar
- Sütunlar adla, esnek tutar/tarih yazımı, ; ve , ayırıcı, Windows-1254 desteği
- Varlıkta tarih sırasına göre eldekinden fazla satış denetimi
- Ayarlar sayfasında Veri bölümü

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 02:21:44 +03:00
suphicakir 33f64a0459 fix(arayüz): sunucuya ulaşılamadığında açıklayıcı hata mesajı
- Vekil 502/503/504 döndüğünde "Beklenmeyen hata" yerine "Sunucu şu an yanıt vermiyor"
- Yerel geliştirmede backend'in kapalı kalması durumu MEMORY'ye not edildi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 02:05:31 +03:00
suphicakir feb2e1d16c feat(raporlar): raporlar sayfası, CSV dışa aktarma ve yazdırma
- Filtreler (kapsam, grup, kategori, ödeme türü) adreste
- Gruplar, tüm kategoriler, ödeme türleri, aylar; bütçe sapması; gelir–gider ve birikim oranı; varlık performansı
- Her bölümde Excel uyumlu CSV (BOM, ;) ve tek bölüm yazdırma
- Yazdırmada açık tema, menüler gizli, kapsam satırı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:59:58 +03:00
suphicakir 87409590a6 feat(raporlar): harcama kırılımı uç noktası ve dönem istatistiklerinde birikim oranı verileri
- GET /reports/breakdown: grup, tüm kategoriler, ödeme türü ve ay kırılımı; dönem/tarih/grup/kategori/ödeme türü filtreleri
- /stats/periods: gelir, sabit gider, devirler, gerçekleşen birikim
- Faz 9 planı kullanıcı kararlarıyla güncellendi (ekstre içe aktarma, JSON geri dönüş noktası, aylık rapor)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:48:46 +03:00
suphicakir 8c9c3c2506 docs: Faz 9 adım planı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:29:30 +03:00
71 changed files with 5637 additions and 40 deletions
+20
View File
@@ -377,3 +377,23 @@ GET {{baseUrl}}/savings/pending
### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez)
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/to-savings
# ─────────────────────────── Raporlar ───────────────────────────
### Harcama kırılımı: grup, tüm kategoriler, ödeme türü, ay (filtreler isteğe bağlı)
GET {{baseUrl}}/reports/breakdown?from=2026-01-01&to=2026-12-31
### Bir dönemde, yalnızca Zorunlu, ödeme türü girilmemiş olanlar
GET {{baseUrl}}/reports/breakdown?period_id=BURAYA-DONEM-ID&group=needs&payment_method_id=none
# ─────────────────────────── CSV dışa / içe aktarma ───────────────────────────
### Harcamaları CSV olarak indir (tarih aralığı isteğe bağlı)
GET {{baseUrl}}/export/entries.csv?from=2026-01-01&to=2026-12-31
### İçe aktarma denemesi (hiçbir şey yazılmaz); commit=true → geçerli satırlar yazılır
POST {{baseUrl}}/import/entries?commit=false
Content-Type: text/csv; charset=utf-8
Tarih;Tutar;Kategori;Açıklama
13.11.2026;45,90;Market;Ekmek
+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,
];
}
+10
View File
@@ -34,6 +34,16 @@ impl Group {
}
}
/// Türkçe ad (CSV dışa/içe aktarma; arayüz metinleri `frontend/src/lib/groups.ts`)
pub fn label(self) -> &'static str {
match self {
Group::Needs => "Zorunlu",
Group::Wants => "İstek",
Group::Culture => "Kültürel",
Group::Unexpected => "Beklenmeyen",
}
}
/// Ekranda gösterim sırası
pub fn order(self) -> usize {
Self::ALL.iter().position(|g| *g == self).unwrap_or(0)
+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(),
}))
}
+24
View File
@@ -2,6 +2,7 @@
mod asset;
mod auth;
mod bank_import;
mod budget;
mod catalog;
mod entry;
@@ -11,12 +12,15 @@ mod note;
mod period;
mod plan;
mod rates;
mod reports;
mod review;
mod spa;
mod stats;
mod transfer;
use axum::{
Router,
extract::DefaultBodyLimit,
routing::{delete, get, patch, post, put},
};
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
@@ -87,6 +91,26 @@ pub fn router(state: AppState) -> Router {
.route("/periods/{id}/review", get(review::get))
.route("/periods/{id}/daily", get(stats::daily))
.route("/stats/periods", get(stats::periods))
.route("/reports/breakdown", get(reports::breakdown))
.route("/export/entries.csv", get(transfer::export_entries))
.route("/export/plan-items.csv", get(transfer::export_plan_items))
.route(
"/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
post(transfer::import).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
)
.route(
"/periods/{id}/budget",
get(budget::get).put(budget::set_monthly),
+173
View File
@@ -0,0 +1,173 @@
//! Rapor uç noktaları (hanenin tüm üyeleri):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/reports/breakdown?period_id=&from=&to=&group=&category_id=&payment_method_id=` | harcama: toplam, grup, tüm kategoriler, ödeme türü, ay |
//!
//! `payment_method_id=none` → ödeme türü girilmemiş kayıtlar. Dönemler arası özet: `/api/stats/periods`.
use axum::{
Json,
extract::{Query, State},
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
models::group::Group,
services::reports::{self, Filter},
state::AppState,
};
#[derive(Deserialize)]
pub struct BreakdownQuery {
period_id: Option<String>,
from: Option<String>,
to: Option<String>,
group: Option<String>,
category_id: Option<String>,
payment_method_id: Option<String>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReportBreakdown")]
pub struct BreakdownDto {
/// Kuruş
#[ts(type = "number")]
total: i64,
#[ts(type = "number")]
count: i64,
/// 4 grup, sabit sırada
groups: Vec<GroupRow>,
/// Harcama olan **tüm** kategoriler, çoktan aza
categories: Vec<CategoryRow>,
/// Ödeme türleri; `null` = girilmemiş
payment_methods: Vec<MethodRow>,
/// Aylar (`"YYYY-MM"`), eskiden yeniye
months: Vec<MonthRow>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReportGroupRow")]
struct GroupRow {
group: Group,
#[ts(type = "number")]
total: i64,
#[ts(type = "number")]
count: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReportCategoryRow")]
struct CategoryRow {
category_id: String,
group: Group,
#[ts(type = "number")]
total: i64,
#[ts(type = "number")]
count: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReportMethodRow")]
struct MethodRow {
payment_method_id: Option<String>,
#[ts(type = "number")]
total: i64,
#[ts(type = "number")]
count: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReportMonthRow")]
struct MonthRow {
/// `"YYYY-MM"`
month: String,
#[ts(type = "number")]
total: i64,
#[ts(type = "number")]
count: i64,
}
fn opt_id(v: Option<&str>, what: &str) -> AppResult<Option<Uuid>> {
match v.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some(s) => Uuid::parse_str(s)
.map(Some)
.map_err(|_| AppError::BadRequest(format!("{what} geçersiz"))),
}
}
pub async fn breakdown(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<BreakdownQuery>,
) -> AppResult<Json<BreakdownDto>> {
let group = match q.group.as_deref().map(str::trim).filter(|g| !g.is_empty()) {
None => None,
Some(g) => Some(
Group::ALL
.into_iter()
.find(|x| x.as_str() == g)
.ok_or_else(|| AppError::BadRequest("Grup geçersiz".into()))?,
),
};
let payment_method = match q.payment_method_id.as_deref().map(str::trim) {
None | Some("") => None,
Some("none") => Some(None),
Some(id) => Some(opt_id(Some(id), "Ödeme türü")?),
};
let filter = Filter {
period_id: opt_id(q.period_id.as_deref(), "Dönem")?,
from: q.from.filter(|s| !s.trim().is_empty()),
to: q.to.filter(|s| !s.trim().is_empty()),
group,
category_id: opt_id(q.category_id.as_deref(), "Kategori")?,
payment_method,
};
let b = reports::breakdown(&s.db, auth.household_id, filter).await?;
Ok(Json(BreakdownDto {
total: b.total,
count: b.count,
groups: b
.groups
.into_iter()
.map(|r| GroupRow {
group: r.key,
total: r.total,
count: r.count,
})
.collect(),
categories: b
.categories
.into_iter()
.map(|r| CategoryRow {
category_id: r.key.category_id.to_string(),
group: r.key.group,
total: r.total,
count: r.count,
})
.collect(),
payment_methods: b
.methods
.into_iter()
.map(|r| MethodRow {
payment_method_id: r.key.map(|u| u.to_string()),
total: r.total,
count: r.count,
})
.collect(),
months: b
.months
.into_iter()
.map(|r| MonthRow {
month: r.key,
total: r.total,
count: r.count,
})
.collect(),
}))
}
+23 -4
View File
@@ -3,7 +3,7 @@
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/daily` | dönemin her günü: 4 grubun harcaması |
//! | GET | `/api/stats/periods?limit=12` | son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim |
//! | GET | `/api/stats/periods?limit=12` | son dönemler (eskiden yeniye): gelir, sabit gider, devirler, harcanabilir, harcanan, gerçekleşen birikim, birikime aktarılan + toplam birikim |
use axum::{
Json,
@@ -76,11 +76,24 @@ struct PeriodStatDto {
last_date: String,
status: PeriodStatus,
#[ts(type = "number")]
income_total: i64,
#[ts(type = "number")]
fixed_expense_total: i64,
#[ts(type = "number")]
savings_goal: i64,
#[ts(type = "number")]
carryover_debt: i64,
#[ts(type = "number")]
carryover_surplus: i64,
#[ts(type = "number")]
spendable: i64,
#[ts(type = "number")]
spent: i64,
/// Harcanan, gruplara göre
spent_by_group: GroupAmounts,
/// Gelir − sabit − borç + artı − harcanan (negatifse açık). Birikim oranı = bu ÷ gelir
#[ts(type = "number")]
realized_savings: i64,
/// Kapanmamışsa `null`
#[ts(type = "number | null")]
to_savings: Option<i64>,
@@ -100,14 +113,20 @@ pub async fn periods(
.map(|x| {
let end = parse_date(&x.period.end_date).expect("geçerli bitiş tarihi");
PeriodStatDto {
realized_savings: x.realized_savings(),
id: x.period.id.to_string(),
name: x.period.name,
start_date: x.period.start_date,
last_date: format_date(end - time::Duration::days(1)),
status: x.period.status,
spendable: x.spendable,
spent: x.spent,
spent_by_group: x.spent_by_group.into(),
income_total: x.summary.income_total,
fixed_expense_total: x.summary.fixed_expense_total,
savings_goal: x.summary.savings_goal,
carryover_debt: x.summary.carryover_debt,
carryover_surplus: x.summary.carryover_surplus,
spendable: x.summary.spendable,
spent: x.summary.spent_total,
spent_by_group: x.summary.spent_by_group.into(),
to_savings: x.period.to_savings,
}
})
+149
View File
@@ -0,0 +1,149 @@
//! CSV dışa / içe aktarma (hanenin tüm üyeleri):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/export/entries.csv?from=&to=` | harcamalar |
//! | GET | `/api/export/plan-items.csv` | plan kalemleri (tüm dönemler) |
//! | GET | `/api/export/asset-transactions.csv` | varlık işlemleri |
//! | POST | `/api/import/{tür}?commit=false\|true` | gövde: CSV metni → rapor; `commit=true` geçerli satırları yazar |
//!
//! `{tür}`: `entries` · `plan-items` · `asset-transactions`.
use axum::{
Json,
extract::{Path, Query, State},
http::header,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
services::{
calendar::{format_date, today_tr},
transfer,
},
state::AppState,
};
fn csv_response(name: &str, body: String) -> impl IntoResponse {
let file = format!("kakeibo-{name}-{}.csv", format_date(today_tr()));
(
[
(header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{file}\""),
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
body,
)
}
#[derive(Deserialize)]
pub struct RangeQuery {
from: Option<String>,
to: Option<String>,
}
pub async fn export_entries(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<RangeQuery>,
) -> AppResult<impl IntoResponse> {
let body = transfer::export_entries(
&s.db,
auth.household_id,
q.from.as_deref().filter(|x| !x.is_empty()),
q.to.as_deref().filter(|x| !x.is_empty()),
)
.await?;
Ok(csv_response("harcamalar", body))
}
pub async fn export_plan_items(
State(s): State<AppState>,
auth: AuthUser,
) -> AppResult<impl IntoResponse> {
let body = transfer::export_plan_items(&s.db, auth.household_id).await?;
Ok(csv_response("plan-kalemleri", body))
}
pub async fn export_asset_txs(
State(s): State<AppState>,
auth: AuthUser,
) -> AppResult<impl IntoResponse> {
let body = transfer::export_asset_txs(&s.db, auth.household_id).await?;
Ok(csv_response("varlik-islemleri", body))
}
#[derive(Deserialize)]
pub struct ImportQuery {
#[serde(default)]
commit: bool,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ImportReport")]
pub struct ReportDto {
/// Başlık hariç dolu satır
total: u32,
/// Eklenebilir (tekrarlar hariç); yazıldıysa yazılan
valid: u32,
/// Veritabanında zaten olduğu için atlanan
duplicates: u32,
/// Yazılan (denemede 0)
imported: u32,
errors: Vec<ImportError>,
/// İlk geçerli satırlar (gösterime hazır)
preview: Vec<Vec<String>>,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct ImportError {
/// Dosyadaki satır numarası (başlık 1)
line: u32,
message: String,
}
pub async fn import(
State(s): State<AppState>,
auth: AuthUser,
Path(kind): Path<String>,
Query(q): Query<ImportQuery>,
body: String,
) -> AppResult<Json<ReportDto>> {
let r = match kind.as_str() {
"entries" => {
transfer::import_entries(&s.db, auth.household_id, auth.user_id, &body, q.commit)
.await?
}
"plan-items" => {
transfer::import_plan_items(&s.db, auth.household_id, &body, q.commit).await?
}
"asset-transactions" => {
transfer::import_asset_txs(&s.db, auth.household_id, auth.user_id, &body, q.commit)
.await?
}
_ => return Err(AppError::NotFound),
};
Ok(Json(ReportDto {
total: r.total as u32,
valid: r.valid as u32,
duplicates: r.duplicates as u32,
imported: r.imported as u32,
errors: r
.errors
.into_iter()
.map(|(line, message)| ImportError {
line: line as u32,
message,
})
.collect(),
preview: r.preview,
}))
}
+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)
+280
View File
@@ -0,0 +1,280 @@
//! CSV okuma/yazma yardımcıları (saf; veritabanı yok). Türkçe Excel uyumlu:
//! yazarken UTF-8 BOM, `;` ayırıcı, CRLF, tutar `1250,50`, tarih `GG.AA.YYYY`.
//! Okurken esnek: BOM, `;` / `,` / sekme (başlık satırından anlaşılır), tırnaklı alanlar,
//! tutar `1.250,50` · `1250,50` · `1250.50`, tarih `13.11.2026` · `13/11/2026` · `2026-11-13`.
use crate::services::calendar::{format_date, parse_date};
pub const BOM: &str = "\u{FEFF}";
// ---------- Yazma ----------
fn quote(s: &str) -> String {
if s.contains([';', '"', '\n', '\r']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
/// Başlık + satırlar → CSV metni (BOM'lu)
pub fn write(headers: &[&str], rows: &[Vec<String>]) -> String {
let mut out = String::from(BOM);
let line = |cells: Vec<String>| cells.iter().map(|c| quote(c)).collect::<Vec<_>>().join(";");
out.push_str(&line(headers.iter().map(|h| h.to_string()).collect()));
out.push_str("\r\n");
for r in rows {
out.push_str(&line(r.clone()));
out.push_str("\r\n");
}
out
}
/// Kuruş → `1250,50`
pub fn money(kurus: i64) -> String {
let sign = if kurus < 0 { "-" } else { "" };
let a = kurus.unsigned_abs();
format!("{sign}{},{:02}", a / 100, a % 100)
}
/// × 1e6 → `5,5` (gereksiz sıfırsız)
pub fn e6(v: i64) -> String {
let sign = if v < 0 { "-" } else { "" };
let a = v.unsigned_abs();
let frac = format!("{:06}", a % 1_000_000);
let frac = frac.trim_end_matches('0');
if frac.is_empty() {
format!("{sign}{}", a / 1_000_000)
} else {
format!("{sign}{},{frac}", a / 1_000_000)
}
}
/// `"2026-11-13"` → `13.11.2026`
pub fn date(iso: &str) -> String {
match iso.split('-').collect::<Vec<_>>().as_slice() {
[y, m, d] => format!("{d}.{m}.{y}"),
_ => iso.to_string(),
}
}
// ---------- Okuma ----------
/// CSV metni → satırlar (başlık dahil). Boş satırlar atlanır; her satırın dosyadaki numarası.
pub fn parse(text: &str) -> Vec<(usize, Vec<String>)> {
let text = text.strip_prefix(BOM).unwrap_or(text);
let first = text.lines().next().unwrap_or("");
let delim = [';', '\t', ',']
.into_iter()
.max_by_key(|d| first.matches(*d).count())
.unwrap_or(';');
let mut rows = Vec::new();
let mut row = Vec::new();
let mut cell = String::new();
let mut in_quotes = false;
let mut line = 1;
let mut row_line = 1;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if in_quotes && chars.peek() == Some(&'"') => {
cell.push('"');
chars.next();
}
'"' if in_quotes => in_quotes = false,
'"' if cell.is_empty() => in_quotes = true,
c if c == delim && !in_quotes => row.push(std::mem::take(&mut cell)),
'\r' if !in_quotes => {}
'\n' if !in_quotes => {
row.push(std::mem::take(&mut cell));
if row.iter().any(|c| !c.trim().is_empty()) {
rows.push((row_line, std::mem::take(&mut row)));
}
row.clear();
line += 1;
row_line = line;
}
'\n' => {
cell.push(c);
line += 1;
}
c => cell.push(c),
}
}
row.push(cell);
if row.iter().any(|c| !c.trim().is_empty()) {
rows.push((row_line, row));
}
rows
}
/// Başlık adı karşılaştırması: küçük harf (Türkçe İ/I), boşluksuz
pub fn norm(s: &str) -> String {
s.trim()
.chars()
.map(|c| match c {
'İ' => 'i',
'I' => 'ı',
c => c.to_lowercase().next().unwrap_or(c),
})
.filter(|c| !c.is_whitespace())
.collect()
}
/// Başlık satırında adı verilen sütunların sırası (ilk eşleşen ad)
pub fn column(headers: &[String], names: &[&str]) -> Option<usize> {
let hs: Vec<String> = headers.iter().map(|h| norm(h)).collect();
names
.iter()
.find_map(|n| hs.iter().position(|h| *h == norm(n)))
}
/// Ondalık metin → (tam, kesir) basamakları; Türkçe ve İngilizce yazımı tanır
fn split_decimal(s: &str, max_frac: usize) -> Option<(bool, String, String)> {
let s: String = s
.trim()
.chars()
.filter(|c| !c.is_whitespace() && *c != '₺' && *c != '\u{a0}')
.collect();
let (neg, s) = match s.strip_prefix('-') {
Some(r) => (true, r.to_string()),
None => (false, s),
};
if s.is_empty() {
return None;
}
let (int, frac) = if let Some((i, f)) = s.rsplit_once(',') {
// Virgül ondalık; noktalar binlik
(i.replace('.', ""), f.to_string())
} else if s.matches('.').count() == 1 {
let (i, f) = s.split_once('.').unwrap();
// Tek nokta + tam 3 rakam → binlik (1.000), değilse ondalık (12.50)
if f.len() == 3 && i != "0" && !i.is_empty() {
(format!("{i}{f}"), String::new())
} else {
(i.to_string(), f.to_string())
}
} else {
(s.replace('.', ""), String::new())
};
let int = if int.is_empty() { "0".to_string() } else { int };
if !int.bytes().all(|b| b.is_ascii_digit())
|| !frac.bytes().all(|b| b.is_ascii_digit())
|| frac.len() > max_frac
{
return None;
}
Some((neg, int, frac))
}
/// `1.250,50` → 125050 kuruş (en fazla 2 ondalık)
pub fn parse_money(s: &str) -> Option<i64> {
let (neg, int, frac) = split_decimal(s, 2)?;
let v =
int.parse::<i64>().ok()?.checked_mul(100)? + format!("{frac:0<2}").parse::<i64>().ok()?;
Some(if neg { -v } else { v })
}
/// `5,5` → 5_500_000 (en fazla 6 ondalık; eksi yok)
pub fn parse_e6(s: &str) -> Option<i64> {
let (neg, int, frac) = split_decimal(s, 6)?;
if neg {
return None;
}
int.parse::<i64>()
.ok()?
.checked_mul(1_000_000)?
.checked_add(format!("{frac:0<6}").parse::<i64>().ok()?)
}
/// `13.11.2026` · `13/11/2026` · `2026-11-13` → `"2026-11-13"`
pub fn parse_date_any(s: &str) -> Option<String> {
let s = s.trim();
let iso = if s.contains('-') && s.len() == 10 && s.as_bytes()[4] == b'-' {
s.to_string()
} else {
let p: Vec<&str> = s.split(['.', '/']).collect();
match p.as_slice() {
[d, m, y] if y.len() == 4 => format!("{y}-{m:0>2}-{d:0>2}"),
_ => return None,
}
};
parse_date(&iso).map(format_date)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_excel_friendly() {
let csv = write(
&["Ad", "Not"],
&[
vec!["Market".into(), "a;b".into()],
vec!["\"x\"".into(), String::new()],
],
);
assert_eq!(
csv,
"\u{FEFF}Ad;Not\r\nMarket;\"a;b\"\r\n\"\"\"x\"\"\";\r\n"
);
assert_eq!(money(125_050), "1250,50");
assert_eq!(money(-5), "-0,05");
assert_eq!(e6(5_500_000), "5,5");
assert_eq!(e6(10_000_000), "10");
assert_eq!(e6(125_000), "0,125");
assert_eq!(date("2026-11-13"), "13.11.2026");
}
#[test]
fn parses_quotes_delimiters_and_line_numbers() {
let rows = parse(
"\u{FEFF}Tarih;Tutar;Açıklama\r\n13.11.2026;1.250,50;\"Kafe; tatlı\"\r\n\r\n14.11.2026;10;\"iki\nsatır\"\n",
);
assert_eq!(rows.len(), 3);
assert_eq!(
rows[1],
(
2,
vec!["13.11.2026".into(), "1.250,50".into(), "Kafe; tatlı".into()]
)
);
assert_eq!(rows[2].0, 4, "boş satır sayılır ama atlanır");
assert_eq!(rows[2].1[2], "iki\nsatır");
// Virgül ayraçlı dosya
let rows = parse("date,amount\n2026-11-13,\"1,5\"\n");
assert_eq!(rows[1].1, vec!["2026-11-13", "1,5"]);
}
#[test]
fn columns_by_name() {
let h: Vec<String> = ["TARİH", "Tutar ", "Ödeme Türü"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(column(&h, &["Tarih"]), Some(0));
assert_eq!(column(&h, &["ödeme türü"]), Some(2));
assert_eq!(column(&h, &["Kategori"]), None);
}
#[test]
fn money_and_dates() {
assert_eq!(parse_money("1.250,50"), Some(125_050));
assert_eq!(parse_money("1250,5"), Some(125_050));
assert_eq!(parse_money("1250.50"), Some(125_050));
assert_eq!(parse_money("1.000"), Some(100_000));
assert_eq!(parse_money("-45,90 ₺"), Some(-4_590));
assert_eq!(parse_money("1,234"), None, "3 ondalık para olmaz");
assert_eq!(parse_money("abc"), None);
assert_eq!(parse_e6("5,5"), Some(5_500_000));
assert_eq!(parse_e6("0.125"), Some(125_000));
assert_eq!(parse_e6("-1"), None);
assert_eq!(parse_date_any("13.11.2026").as_deref(), Some("2026-11-13"));
assert_eq!(parse_date_any("3/1/2026").as_deref(), Some("2026-01-03"));
assert_eq!(parse_date_any("2026-11-13").as_deref(), Some("2026-11-13"));
assert_eq!(parse_date_any("30.02.2026"), None);
assert_eq!(parse_date_any("13.11.26"), None);
}
}
+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?;
+4
View File
@@ -2,10 +2,12 @@
pub mod account;
pub mod asset;
pub mod bank_import;
pub mod budget;
pub mod calendar;
pub mod catalog;
pub mod closing;
pub mod csv_io;
pub mod entry;
pub mod holding;
pub mod invite;
@@ -15,6 +17,8 @@ pub mod period;
pub mod plan;
pub mod rate_sources;
pub mod rates;
pub mod reports;
pub mod review;
pub mod seed;
pub mod stats;
pub mod transfer;
+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,
+164
View File
@@ -0,0 +1,164 @@
//! Raporlar (KAKEIBO_PROMPT §8.8). Harcama kırılımı tek aggregation (`$facet`) ile:
//! grup, **tüm** kategoriler, ödeme türü, ay. Saklanmaz; her istekte hesaplanır.
//!
//! Filtre: dönem **ya da** tarih aralığı (ikisi de dahil), grup, kategori, ödeme türü
//! (`Some(None)` → ödeme türü girilmemiş kayıtlar). Silinen kayıtlar sayılmaz.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use serde::Deserialize;
use crate::{
db::col,
error::{AppError, AppResult},
models::{common::scope, group::Group},
services::{calendar::parse_date, period},
};
#[derive(Default)]
pub struct Filter {
pub period_id: Option<Uuid>,
pub from: Option<String>,
pub to: Option<String>,
pub group: Option<Group>,
pub category_id: Option<Uuid>,
/// `Some(None)` → ödeme türü boş olanlar
pub payment_method: Option<Option<Uuid>>,
}
#[derive(Debug, Deserialize)]
pub struct Row<K> {
#[serde(rename = "_id")]
pub key: K,
pub total: i64,
pub count: i64,
}
#[derive(Debug, Deserialize)]
pub struct CategoryKey {
pub category_id: Uuid,
pub group: Group,
}
#[derive(Debug, Deserialize)]
struct Facets {
totals: Vec<Row<Bson>>,
groups: Vec<Row<Group>>,
categories: Vec<Row<CategoryKey>>,
methods: Vec<Row<Option<Uuid>>>,
months: Vec<Row<String>>,
}
pub struct Breakdown {
pub total: i64,
pub count: i64,
/// `Group::ALL` sırasıyla, harcama olmayan grup 0
pub groups: Vec<Row<Group>>,
/// Çoktan aza (eşitlikte kategori kimliği)
pub categories: Vec<Row<CategoryKey>>,
/// Çoktan aza; `None` = ödeme türü girilmemiş
pub methods: Vec<Row<Option<Uuid>>>,
/// `"YYYY-MM"`, eskiden yeniye
pub months: Vec<Row<String>>,
}
pub async fn breakdown(db: &Database, household_id: Uuid, f: Filter) -> AppResult<Breakdown> {
let mut m = scope(household_id);
if let Some(pid) = f.period_id {
period::get(db, household_id, pid).await?; // bu haneye ait mi?
m.insert("period_id", pid);
}
let mut range = Document::new();
for (op, v) in [("$gte", &f.from), ("$lte", &f.to)] {
if let Some(d) = v {
let d = d.trim();
parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
range.insert(op, d);
}
}
if let (Some(a), Some(b)) = (&f.from, &f.to)
&& a.trim() > b.trim()
{
return Err(AppError::BadRequest(
"Başlangıç tarihi bitişten sonra olamaz".into(),
));
}
if !range.is_empty() {
m.insert("date", range);
}
if let Some(g) = f.group {
m.insert("group", g.as_str());
}
if let Some(c) = f.category_id {
m.insert("category_id", c);
}
if let Some(pm) = f.payment_method {
m.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
}
let sum = || doc! { "total": { "$sum": "$amount" }, "count": { "$sum": Bson::Int64(1) } };
let group_by = |key: Bson| {
let mut g = doc! { "_id": key };
g.extend(sum());
doc! { "$group": g }
};
let pipeline = vec![
doc! { "$match": m },
doc! { "$facet": {
"totals": [ group_by(Bson::Null) ],
"groups": [ group_by(Bson::String("$group".into())) ],
"categories": [
group_by(Bson::Document(doc! { "category_id": "$category_id", "group": "$group" })),
{ "$sort": { "total": -1, "_id.category_id": 1 } },
],
"methods": [
// Alanı hiç olmayan eski kayıtlar da "girilmemiş" sayılsın
group_by(Bson::Document(doc! { "$ifNull": ["$payment_method_id", null] })),
{ "$sort": { "total": -1, "_id": 1 } },
],
"months": [
group_by(Bson::Document(doc! { "$substrBytes": ["$date", 0, 7] })),
{ "$sort": { "_id": 1 } },
],
} },
];
let mut cursor = db
.collection::<Document>(col::ENTRIES)
.aggregate(pipeline)
.with_type::<Facets>()
.await?;
let f = if cursor.advance().await? {
cursor.deserialize_current()?
} else {
return Err(AppError::Internal("rapor sorgusu boş döndü".into()));
};
let (total, count) = f.totals.first().map_or((0, 0), |r| (r.total, r.count));
let groups = Group::ALL
.into_iter()
.map(|g| {
f.groups.iter().find(|r| r.key == g).map_or(
Row {
key: g,
total: 0,
count: 0,
},
|r| Row {
key: g,
total: r.total,
count: r.count,
},
)
})
.collect();
Ok(Breakdown {
total,
count,
groups,
categories: f.categories,
methods: f.methods,
months: f.months,
})
}
+13 -10
View File
@@ -57,9 +57,17 @@ pub async fn daily(db: &Database, household_id: Uuid, period_id: Uuid) -> AppRes
pub struct PeriodStat {
pub period: Period,
pub spendable: i64,
pub spent: i64,
pub spent_by_group: GroupAmounts,
/// Gelir, sabit gider, devirler, harcanabilir, harcanan (gruplara göre dahil)
pub summary: budget::Summary,
}
impl PeriodStat {
/// Değerlendirmenin 4. sorusu: gelir − sabit − borç + artı − harcanan (`review` ile aynı)
pub fn realized_savings(&self) -> i64 {
let s = &self.summary;
s.income_total - s.fixed_expense_total - s.carryover_debt + s.carryover_surplus
- s.spent_total
}
}
pub struct Stats {
@@ -74,13 +82,8 @@ pub async fn periods(db: &Database, household_id: Uuid, limit: usize) -> AppResu
let total_saved = list.iter().filter_map(|p| p.to_savings).sum();
let mut out = Vec::new();
for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) {
let s = budget::summary(db, household_id, p.id).await?;
out.push(PeriodStat {
period: p,
spendable: s.spendable,
spent: s.spent_total,
spent_by_group: s.spent_by_group,
});
let summary = budget::summary(db, household_id, p.id).await?;
out.push(PeriodStat { period: p, summary });
}
out.reverse();
Ok(Stats {
+805
View File
@@ -0,0 +1,805 @@
//! Uygulamanın kendi CSV biçimiyle dışa / içe aktarma (KAKEIBO_PROMPT §8.9):
//! harcamalar, plan kalemleri, varlık işlemleri.
//!
//! İçe aktarma iki aşamalı: `commit = false` → yalnızca rapor (deneme); `true` → geçerli satırlar
//! yazılır. Hatalı satırlar satır numarası ve nedeniyle raporlanır, diğerleri etkilenmez.
//! **Aynı dosya iki kez yüklense de çift kayıt olmaz:** veritabanında aynısı olan satır atlanır
//! (dosyada aynı satırdan iki tane, veritabanında bir tane varsa biri eklenir).
//! Sütunlar başlık adıyla bulunur (sıra serbest); kategori / ödeme türü / varlık adla eşleşir.
use std::collections::HashMap;
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
asset::{Asset, AssetKind, AssetTx, TxType},
common::{new_meta, scope},
entry::Entry,
group::Group,
period::Period,
plan_item::{PlanItem, PlanKind},
},
services::{
asset,
catalog::{self, Catalog},
csv_io::{self, column, norm},
entry::MAX_DESCRIPTION,
holding::{self, Move},
period,
plan::{self, NewItem},
},
};
/// Bir dosyada en fazla satır
pub const MAX_ROWS: usize = 20_000;
/// Önizlemede gösterilen geçerli satır sayısı
const PREVIEW: usize = 10;
#[derive(Debug, Default)]
pub struct Report {
/// Başlık hariç dolu satır sayısı
pub total: usize,
/// Eklenebilir satırlar (tekrarlar hariç)
pub valid: usize,
/// Veritabanında zaten olduğu için atlanan
pub duplicates: usize,
/// Yazılan (deneme: 0)
pub imported: usize,
/// (satır numarası, neden)
pub errors: Vec<(usize, String)>,
/// İlk geçerli satırlar, gösterime hazır hücreler
pub preview: Vec<Vec<String>>,
}
/// (satır numarası, hücreler)
type Rows = Vec<(usize, Vec<String>)>;
fn rows_of(text: &str) -> AppResult<(Vec<String>, Rows)> {
let mut rows = csv_io::parse(text);
if rows.is_empty() {
return Err(AppError::BadRequest("Dosya boş".into()));
}
if rows.len() - 1 > MAX_ROWS {
return Err(AppError::BadRequest(format!(
"Dosya en fazla {MAX_ROWS} satır olabilir; parçalara bölün"
)));
}
let header = rows.remove(0).1;
Ok((header, rows))
}
/// Zorunlu sütunlar: (anahtar adlar) → sıra; eksikse anlaşılır hata
fn require(header: &[String], cols: &[(&str, &[&str])]) -> AppResult<Vec<usize>> {
let mut out = Vec::new();
let mut missing = Vec::new();
for (label, names) in cols {
match column(header, names) {
Some(i) => out.push(i),
None => missing.push(*label),
}
}
if !missing.is_empty() {
return Err(AppError::BadRequest(format!(
"Gerekli sütun eksik: {}",
missing.join(", ")
)));
}
Ok(out)
}
fn cell(row: &[String], i: Option<usize>) -> &str {
i.and_then(|i| row.get(i)).map_or("", |s| s.trim())
}
fn period_for<'a>(periods: &'a [Period], date: &str) -> Result<&'a Period, String> {
let p = periods
.iter()
.find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str())
.ok_or_else(|| "Bu tarih için dönem yok".to_string())?;
if !p.status.is_writable() {
return Err(format!("Dönem kapalı ({})", p.name));
}
Ok(p)
}
fn group_from(s: &str) -> Option<Group> {
let n = norm(s);
Group::ALL
.into_iter()
.find(|g| norm(g.label()) == n || g.as_str() == n)
}
/// Ada göre katalog kaydı (grup verilmişse o grupta). Aynı ad birden çok grupta → hata.
fn find_by_name<'a>(
items: &'a [catalog::Item],
name: &str,
group: Option<Group>,
what: &str,
) -> Result<&'a catalog::Item, String> {
let n = norm(name);
let matches: Vec<&catalog::Item> = items
.iter()
.filter(|c| norm(&c.name) == n && (group.is_none() || c.group == group))
.collect();
match matches.as_slice() {
[one] => Ok(one),
[] => Err(format!("{what} bulunamadı: {name}")),
_ => Err(format!(
"\"{name}\" birden fazla grupta var; Grup sütununu doldurun"
)),
}
}
/// Tekrar sayacı: anahtar → veritabanında kaç tane var (eşleştikçe azalır)
struct Existing(HashMap<String, usize>);
impl Existing {
fn new(keys: impl Iterator<Item = String>) -> Self {
let mut m = HashMap::new();
for k in keys {
*m.entry(k).or_insert(0) += 1;
}
Self(m)
}
/// Varsa bir tanesini "tüket" ve `true` dön
fn take(&mut self, key: &str) -> bool {
match self.0.get_mut(key) {
Some(n) if *n > 0 => {
*n -= 1;
true
}
_ => false,
}
}
}
// ================= Harcamalar =================
const E_HEADERS: [&str; 6] = [
"Tarih",
"Tutar",
"Grup",
"Kategori",
"Ödeme türü",
"Açıklama",
];
fn entry_key(date: &str, amount: i64, category: Uuid, desc: Option<&str>) -> String {
format!("{date}|{amount}|{category}|{}", desc.unwrap_or("").trim())
}
pub async fn export_entries(
db: &Database,
household_id: Uuid,
from: Option<&str>,
to: Option<&str>,
) -> AppResult<String> {
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
let pms = catalog::list(db, Catalog::PaymentMethods, household_id, true).await?;
let mut f = scope(household_id);
let mut range = Document::new();
if let Some(d) = from {
range.insert("$gte", d);
}
if let Some(d) = to {
range.insert("$lte", d);
}
if !range.is_empty() {
f.insert("date", range);
}
let mut c = db
.collection::<Entry>(col::ENTRIES)
.find(f)
.sort(doc! { "date": 1, "_id": 1 })
.await?;
let mut rows = Vec::new();
while c.advance().await? {
let e = c.deserialize_current()?;
let cat = cats.iter().find(|x| x.id == e.category_id);
let pm = e
.payment_method_id
.and_then(|id| pms.iter().find(|x| x.id == id));
rows.push(vec![
csv_io::date(&e.date),
csv_io::money(e.amount),
e.group.label().to_string(),
cat.map_or(String::new(), |x| x.name.clone()),
pm.map_or(String::new(), |x| x.name.clone()),
e.description.unwrap_or_default(),
]);
}
Ok(csv_io::write(&E_HEADERS, &rows))
}
pub async fn import_entries(
db: &Database,
household_id: Uuid,
user_id: Uuid,
text: &str,
commit: bool,
) -> AppResult<Report> {
let (header, rows) = rows_of(text)?;
let req = require(
&header,
&[
("Tarih", &["Tarih", "date"]),
("Tutar", &["Tutar", "amount"]),
("Kategori", &["Kategori", "category"]),
],
)?;
let (c_date, c_amount, c_cat) = (Some(req[0]), Some(req[1]), Some(req[2]));
let c_group = column(&header, &["Grup", "group"]);
let c_pm = column(&header, &["Ödeme türü", "Ödeme", "payment_method"]);
let c_desc = column(&header, &["Açıklama", "description"]);
let periods = period::list(db, household_id).await?;
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
let pms = catalog::list(db, Catalog::PaymentMethods, household_id, true).await?;
let mut existing = {
let mut c = db
.collection::<Entry>(col::ENTRIES)
.find(scope(household_id))
.await?;
let mut keys = Vec::new();
while c.advance().await? {
let e = c.deserialize_current()?;
keys.push(entry_key(
&e.date,
e.amount,
e.category_id,
e.description.as_deref(),
));
}
Existing::new(keys.into_iter())
};
let mut report = Report {
total: rows.len(),
..Default::default()
};
let mut docs = Vec::new();
for (line, row) in &rows {
let parsed = (|| -> Result<Document, String> {
let date = csv_io::parse_date_any(cell(row, c_date))
.ok_or_else(|| format!("Tarih geçersiz: {}", cell(row, c_date)))?;
let p = period_for(&periods, &date)?;
let amount = csv_io::parse_money(cell(row, c_amount))
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_amount)))?;
if amount <= 0 {
return Err("Tutar sıfırdan büyük olmalı".into());
}
period::validate_amount(amount).map_err(|e| e.to_string())?;
let group = match cell(row, c_group) {
"" => None,
g => Some(group_from(g).ok_or_else(|| format!("Grup tanınmadı: {g}"))?),
};
let cat = find_by_name(&cats, cell(row, c_cat), group, "Kategori")?;
let cat_group = cat.group.ok_or("Kategorinin grubu yok")?;
let pm = match cell(row, c_pm) {
"" => None,
n => Some(find_by_name(&pms, n, None, "Ödeme türü")?.id),
};
let desc = match cell(row, c_desc) {
"" => None,
d if d.chars().count() > MAX_DESCRIPTION => {
return Err(format!(
"Açıklama en fazla {MAX_DESCRIPTION} karakter olabilir"
));
}
d => Some(d.to_string()),
};
let mut d = new_meta(household_id);
d.insert("period_id", p.id);
d.insert("date", &date);
d.insert("amount", amount);
d.insert("group", cat_group.as_str());
d.insert("category_id", cat.id);
d.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
d.insert("description", desc.clone().map_or(Bson::Null, Bson::String));
d.insert("created_by", user_id);
d.insert("__key", entry_key(&date, amount, cat.id, desc.as_deref()));
d.insert(
"__preview",
vec![
csv_io::date(&date),
csv_io::money(amount),
cat.name.clone(),
desc.unwrap_or_default(),
],
);
Ok(d)
})();
match parsed {
Err(e) => report.errors.push((*line, e)),
Ok(mut d) => {
let key = d.get_str("__key").unwrap_or_default().to_string();
if existing.take(&key) {
report.duplicates += 1;
continue;
}
if report.preview.len() < PREVIEW
&& let Ok(p) = d.get_array("__preview")
{
report.preview.push(
p.iter()
.map(|x| x.as_str().unwrap_or_default().to_string())
.collect(),
);
}
d.remove("__key");
d.remove("__preview");
docs.push(d);
}
}
}
report.valid = docs.len();
if commit && !docs.is_empty() {
db.collection::<Document>(col::ENTRIES)
.insert_many(docs)
.await?;
report.imported = report.valid;
}
Ok(report)
}
// ================= Plan kalemleri =================
const P_HEADERS: [&str; 6] = ["Dönem başlangıcı", "Dönem", "Tür", "Ad", "Tutar", "Tarih"];
fn kind_label(k: PlanKind) -> &'static str {
match k {
PlanKind::Income => "Gelir",
PlanKind::Expense => "Sabit gider",
}
}
fn kind_from(s: &str) -> Option<PlanKind> {
match norm(s).as_str() {
"gelir" | "income" => Some(PlanKind::Income),
"gider" | "sabitgider" | "expense" => Some(PlanKind::Expense),
_ => None,
}
}
fn plan_key(period: Uuid, kind: PlanKind, name: &str, amount: i64) -> String {
format!("{period}|{}|{}|{amount}", kind.as_str(), norm(name))
}
async fn all_plan_items(db: &Database, household_id: Uuid) -> AppResult<Vec<PlanItem>> {
let mut c = db
.collection::<PlanItem>(col::PLAN_ITEMS)
.find(scope(household_id))
.sort(doc! { "period_id": 1, "kind": 1, "sort_order": 1 })
.await?;
let mut out = Vec::new();
while c.advance().await? {
out.push(c.deserialize_current()?);
}
Ok(out)
}
pub async fn export_plan_items(db: &Database, household_id: Uuid) -> AppResult<String> {
let mut periods = period::list(db, household_id).await?;
periods.reverse(); // eskiden yeniye
let items = all_plan_items(db, household_id).await?;
let mut rows = Vec::new();
for p in &periods {
// Uygulamadaki sıra: önce gelirler, sonra sabit giderler
let mut list: Vec<&PlanItem> = items.iter().filter(|i| i.period_id == p.id).collect();
list.sort_by_key(|i| (i.kind != PlanKind::Income, i.sort_order));
for i in list {
rows.push(vec![
csv_io::date(&p.start_date),
p.name.clone(),
kind_label(i.kind).to_string(),
i.name.clone(),
csv_io::money(i.amount),
i.date.as_deref().map(csv_io::date).unwrap_or_default(),
]);
}
}
Ok(csv_io::write(&P_HEADERS, &rows))
}
pub async fn import_plan_items(
db: &Database,
household_id: Uuid,
text: &str,
commit: bool,
) -> AppResult<Report> {
let (header, rows) = rows_of(text)?;
let req = require(
&header,
&[
("Dönem başlangıcı", &["Dönem başlangıcı", "period_start"]),
("Tür", &["Tür", "kind"]),
("Ad", &["Ad", "name"]),
("Tutar", &["Tutar", "amount"]),
],
)?;
let (c_period, c_kind, c_name, c_amount) =
(Some(req[0]), Some(req[1]), Some(req[2]), Some(req[3]));
let c_date = column(&header, &["Tarih", "date"]);
let periods = period::list(db, household_id).await?;
let mut existing = Existing::new(
all_plan_items(db, household_id)
.await?
.iter()
.map(|i| plan_key(i.period_id, i.kind, &i.name, i.amount)),
);
let mut report = Report {
total: rows.len(),
..Default::default()
};
let mut todo: Vec<(usize, Uuid, NewItem)> = Vec::new();
for (line, row) in &rows {
let parsed = (|| -> Result<(Uuid, NewItem, Vec<String>), String> {
let start = csv_io::parse_date_any(cell(row, c_period))
.ok_or_else(|| format!("Dönem başlangıcı geçersiz: {}", cell(row, c_period)))?;
let p = period_for(&periods, &start)?;
let kind = kind_from(cell(row, c_kind)).ok_or_else(|| {
format!(
"Tür \"Gelir\" ya da \"Sabit gider\" olmalı: {}",
cell(row, c_kind)
)
})?;
let name = cell(row, c_name);
if name.is_empty() {
return Err("Ad boş".into());
}
let amount = csv_io::parse_money(cell(row, c_amount))
.filter(|a| *a > 0)
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_amount)))?;
let date = match cell(row, c_date) {
"" => None,
d => Some(csv_io::parse_date_any(d).ok_or_else(|| format!("Tarih geçersiz: {d}"))?),
};
let preview = vec![
p.name.clone(),
kind_label(kind).into(),
name.into(),
csv_io::money(amount),
];
Ok((
p.id,
NewItem {
kind,
name: name.to_string(),
amount,
date,
},
preview,
))
})();
match parsed {
Err(e) => report.errors.push((*line, e)),
Ok((pid, item, preview)) => {
if existing.take(&plan_key(pid, item.kind, &item.name, item.amount)) {
report.duplicates += 1;
continue;
}
if report.preview.len() < PREVIEW {
report.preview.push(preview);
}
todo.push((*line, pid, item));
}
}
}
report.valid = todo.len();
if commit {
// Ad uzunluğu, tarihin dönem içinde olması vb. son denetim `plan::create`'te
for (line, pid, item) in todo {
match plan::create(db, household_id, pid, item).await {
Ok(_) => report.imported += 1,
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => {
report.errors.push((line, m));
}
Err(e) => return Err(e),
}
}
report.valid = report.imported;
report.errors.sort_by_key(|(l, _)| *l);
}
Ok(report)
}
// ================= Varlık işlemleri =================
const A_HEADERS: [&str; 8] = [
"Tarih",
"Varlık kodu",
"Varlık",
"İşlem",
"Miktar",
"Birim fiyat",
"Tutar",
"Not",
];
fn tx_key(asset: Uuid, date: &str, t: TxType, q: i64, total: i64) -> String {
format!("{asset}|{date}|{}|{q}|{total}", t.as_str())
}
async fn household_assets(db: &Database, household_id: Uuid) -> AppResult<Vec<Asset>> {
let mut c = db
.collection::<Asset>(col::ASSETS)
.find(scope(household_id))
.sort(doc! { "sort_order": 1 })
.await?;
let mut out = Vec::new();
while c.advance().await? {
out.push(c.deserialize_current()?);
}
Ok(out)
}
async fn household_txs(db: &Database, household_id: Uuid) -> AppResult<Vec<AssetTx>> {
let mut c = db
.collection::<AssetTx>(col::ASSET_TRANSACTIONS)
.find(scope(household_id))
.sort(doc! { "date": 1, "_id": 1 })
.await?;
let mut out = Vec::new();
while c.advance().await? {
out.push(c.deserialize_current()?);
}
Ok(out)
}
pub async fn export_asset_txs(db: &Database, household_id: Uuid) -> AppResult<String> {
let assets = household_assets(db, household_id).await?;
let rows = household_txs(db, household_id)
.await?
.into_iter()
.map(|t| {
let a = assets.iter().find(|a| a.id == t.asset_id);
vec![
csv_io::date(&t.date),
a.map_or(String::new(), |a| a.code.clone()),
a.map_or(String::new(), |a| a.name.clone()),
match t.tx_type {
TxType::Buy => "Alım",
TxType::Sell => "Satım",
}
.to_string(),
csv_io::e6(t.quantity_e6),
csv_io::e6(t.unit_price_e6),
csv_io::money(t.total),
t.note.unwrap_or_default(),
]
})
.collect::<Vec<_>>();
Ok(csv_io::write(&A_HEADERS, &rows))
}
struct NewTxRow {
line: usize,
asset: Uuid,
tx: asset::NewTx,
quantity_e6: i64,
}
pub async fn import_asset_txs(
db: &Database,
household_id: Uuid,
user_id: Uuid,
text: &str,
commit: bool,
) -> AppResult<Report> {
let (header, rows) = rows_of(text)?;
let req = require(
&header,
&[
("Tarih", &["Tarih", "date"]),
("İşlem", &["İşlem", "type"]),
("Tutar", &["Tutar", "total"]),
],
)?;
let (c_date, c_type, c_total) = (Some(req[0]), Some(req[1]), Some(req[2]));
let c_code = column(&header, &["Varlık kodu", "Kod", "code"]);
let c_name = column(&header, &["Varlık", "asset"]);
if c_code.is_none() && c_name.is_none() {
return Err(AppError::BadRequest(
"Gerekli sütun eksik: Varlık kodu (ya da Varlık)".into(),
));
}
let c_qty = column(&header, &["Miktar", "quantity"]);
let c_price = column(&header, &["Birim fiyat", "unit_price"]);
let c_note = column(&header, &["Not", "note"]);
let assets = household_assets(db, household_id).await?;
let existing_txs = household_txs(db, household_id).await?;
let mut existing = Existing::new(
existing_txs
.iter()
.map(|t| tx_key(t.asset_id, &t.date, t.tx_type, t.quantity_e6, t.total)),
);
let mut report = Report {
total: rows.len(),
..Default::default()
};
let mut todo: Vec<NewTxRow> = Vec::new();
for (line, row) in &rows {
let parsed = (|| -> Result<(NewTxRow, Vec<String>), String> {
let a = {
let code = norm(cell(row, c_code));
let name = norm(cell(row, c_name));
assets
.iter()
.find(|a| {
(!code.is_empty() && norm(&a.code) == code)
|| (code.is_empty() && norm(&a.name) == name)
})
.ok_or_else(|| {
format!(
"Varlık bulunamadı: {}",
if code.is_empty() {
cell(row, c_name)
} else {
cell(row, c_code)
}
)
})?
};
if !a.is_active {
return Err(format!("Varlık pasif: {}", a.name));
}
let date = csv_io::parse_date_any(cell(row, c_date))
.ok_or_else(|| format!("Tarih geçersiz: {}", cell(row, c_date)))?;
let tx_type = match norm(cell(row, c_type)).as_str() {
"alım" | "alis" | "alış" | "buy" => TxType::Buy,
"satım" | "satış" | "satis" | "sell" => TxType::Sell,
t => return Err(format!("İşlem \"Alım\" ya da \"Satım\" olmalı: {t}")),
};
let total = csv_io::parse_money(cell(row, c_total))
.filter(|t| *t > 0)
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_total)))?;
let quantity_e6 = if a.kind == AssetKind::Cash {
total * 10_000
} else {
csv_io::parse_e6(cell(row, c_qty))
.filter(|q| *q > 0)
.ok_or_else(|| format!("Miktar geçersiz: {}", cell(row, c_qty)))?
};
let unit_price_e6 = match cell(row, c_price) {
"" => None,
p => Some(
csv_io::parse_e6(p)
.filter(|p| *p > 0)
.ok_or_else(|| format!("Birim fiyat geçersiz: {p}"))?,
),
};
let note = match cell(row, c_note) {
"" => None,
n => Some(n.to_string()),
};
let preview = vec![
csv_io::date(&date),
a.name.clone(),
if tx_type == TxType::Buy {
"Alım"
} else {
"Satım"
}
.into(),
if a.kind == AssetKind::Cash {
csv_io::money(total)
} else {
format!("{} {}", csv_io::e6(quantity_e6), a.unit_label)
},
];
Ok((
NewTxRow {
line: *line,
asset: a.id,
quantity_e6,
tx: asset::NewTx {
tx_type,
date,
quantity_e6,
unit_price_e6,
total,
note,
},
},
preview,
))
})();
match parsed {
Err(e) => report.errors.push((*line, e)),
Ok((r, preview)) => {
if existing.take(&tx_key(
r.asset,
&r.tx.date,
r.tx.tx_type,
r.quantity_e6,
r.tx.total,
)) {
report.duplicates += 1;
continue;
}
if report.preview.len() < PREVIEW {
report.preview.push(preview);
}
todo.push(r);
}
}
}
// Tarih sırasıyla (aynı gün dosya sırası) eldekinden fazla satış denetimi:
// mevcut işlemler + kabul edilen yeniler; aşan satır reddedilir, diğerleri sürer.
todo.sort_by(|a, b| (a.tx.date.as_str(), a.line).cmp(&(b.tx.date.as_str(), b.line)));
let mut accepted: Vec<NewTxRow> = Vec::new();
for r in todo {
let mut list: Vec<(String, u8, usize, Move)> = existing_txs
.iter()
.filter(|t| t.asset_id == r.asset)
.enumerate()
.map(|(i, t)| {
(
t.date.clone(),
0,
i,
Move {
tx_type: t.tx_type,
quantity_e6: t.quantity_e6,
total: t.total,
},
)
})
.chain(
accepted
.iter()
.chain(std::iter::once(&r))
.filter(|x| x.asset == r.asset)
.map(|x| {
(
x.tx.date.clone(),
1,
x.line,
Move {
tx_type: x.tx.tx_type,
quantity_e6: x.quantity_e6,
total: x.tx.total,
},
)
}),
)
.collect();
list.sort_by(|a, b| (a.0.as_str(), a.1, a.2).cmp(&(b.0.as_str(), b.1, b.2)));
let moves: Vec<Move> = list.into_iter().map(|x| x.3).collect();
if holding::holding(&moves).is_err() {
report.errors.push((
r.line,
"Satış miktarı o tarihte eldeki miktarı aşıyor".into(),
));
} else {
accepted.push(r);
}
}
report.errors.sort_by_key(|(l, _)| *l);
report.valid = accepted.len();
if commit {
report.valid = 0;
for r in accepted {
match asset::create_tx(db, household_id, user_id, r.asset, r.tx).await {
Ok(_) => report.imported += 1,
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => {
report.errors.push((r.line, m))
}
Err(e) => return Err(e),
}
}
report.valid = report.imported;
report.errors.sort_by_key(|(l, _)| *l);
}
Ok(report)
}
+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;
}
+37
View File
@@ -101,6 +101,43 @@ impl TestApp {
(status, json, set_cookie)
}
/// Düz metin gövde gönderir (CSV içe aktarma)
pub async fn post_text(&self, path: &str, s: &Session, text: &str) -> (StatusCode, Value) {
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api{path}"))
.header(header::COOKIE, &s.cookie)
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
.body(Body::from(text.to_string()))
.unwrap();
let res = self.router.clone().oneshot(req).await.unwrap();
let status = res.status();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(
status,
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
)
}
/// Metin yanıt (CSV dışa aktarma): (durum, içerik türü, gövde)
pub async fn get_text(&self, path: &str, s: &Session) -> (StatusCode, String, String) {
let req = Request::builder()
.uri(format!("/api{path}"))
.header(header::COOKIE, &s.cookie)
.body(Body::empty())
.unwrap();
let res = self.router.clone().oneshot(req).await.unwrap();
let status = res.status();
let ct = res
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(status, ct, String::from_utf8(bytes.to_vec()).unwrap())
}
pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) {
let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await;
(st, v)
+191
View File
@@ -0,0 +1,191 @@
//! Raporlar: harcama kırılımı (grup, tüm kategoriler, ödeme türü, ay) ve filtreler.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::{Value, json};
async fn id_by_name(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()
}
async fn spend(
app: &TestApp,
s: &Session,
pid: &str,
date: &str,
amount: i64,
cat: &str,
pm: Option<&str>,
) -> Value {
let mut body = json!({ "date": date, "amount": amount, "category_id": cat });
if let Some(pm) = pm {
body["payment_method_id"] = json!(pm);
}
let (st, e) = app.post(&format!("/periods/{pid}/entries"), s, body).await;
assert_eq!(st, StatusCode::CREATED, "{e}");
e
}
async fn report(app: &TestApp, s: &Session, query: &str) -> Value {
let (st, b) = app.get(&format!("/reports/breakdown{query}"), s).await;
assert_eq!(st, StatusCode::OK, "{query} → {b}");
b
}
fn totals(list: &Value, key: &str) -> Vec<(String, i64)> {
list.as_array()
.unwrap()
.iter()
.map(|r| {
(
r[key].as_str().unwrap_or("-").to_string(),
r["total"].as_i64().unwrap(),
)
})
.collect()
}
#[tokio::test]
async fn breakdown_with_filters() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let market = id_by_name(&app, &s, "/categories", "Market").await;
let giyim = id_by_name(&app, &s, "/categories", "Giyim").await;
let kitap = id_by_name(&app, &s, "/categories", "Kitap").await;
let nakit = id_by_name(&app, &s, "/payment-methods", "Nakit").await;
let kart = id_by_name(&app, &s, "/payment-methods", "Kredi Kartı").await;
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();
spend(&app, &s, &oct, "2026-10-05", 100_000, &market, Some(&nakit)).await;
spend(&app, &s, &oct, "2026-10-20", 50_000, &giyim, None).await;
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();
spend(&app, &s, &nov, "2026-11-02", 30_000, &market, Some(&kart)).await;
spend(&app, &s, &nov, "2026-11-03", 20_000, &kitap, Some(&nakit)).await;
spend(&app, &s, &nov, "2026-11-04", 10_000, &market, Some(&nakit)).await;
let gone = spend(&app, &s, &nov, "2026-11-05", 99_000, &kitap, None).await;
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
.await;
// Filtresiz: tüm zamanlar
let r = report(&app, &s, "").await;
assert_eq!(r["total"], 210_000);
assert_eq!(r["count"], 5, "silinen sayılmaz");
assert_eq!(
totals(&r["groups"], "group"),
[
("needs".into(), 140_000),
("wants".into(), 50_000),
("culture".into(), 20_000),
("unexpected".into(), 0)
]
);
assert_eq!(
totals(&r["categories"], "category_id"),
[
(market.clone(), 140_000),
(giyim.clone(), 50_000),
(kitap.clone(), 20_000)
]
);
assert_eq!(r["categories"][0]["count"], 3);
assert_eq!(r["categories"][0]["group"], "needs");
assert_eq!(
totals(&r["payment_methods"], "payment_method_id"),
[
(nakit.clone(), 130_000),
("-".into(), 50_000),
(kart.clone(), 30_000)
],
"ödeme türü girilmemiş → null"
);
assert_eq!(
totals(&r["months"], "month"),
[("2026-10".into(), 150_000), ("2026-11".into(), 60_000)]
);
// Dönem
assert_eq!(
report(&app, &s, &format!("?period_id={nov}")).await["total"],
60_000
);
// Tarih aralığı iki dönemi keser (ikisi de dahil)
let r = report(&app, &s, "?from=2026-10-15&to=2026-11-03").await;
assert_eq!(
(r["total"].as_i64(), r["count"].as_i64()),
(Some(100_000), Some(3))
);
// Grup, kategori, ödeme türü (+ girilmemiş)
assert_eq!(report(&app, &s, "?group=needs").await["total"], 140_000);
assert_eq!(
report(&app, &s, &format!("?category_id={kitap}")).await["total"],
20_000
);
assert_eq!(
report(&app, &s, &format!("?payment_method_id={nakit}")).await["total"],
130_000
);
assert_eq!(
report(&app, &s, "?payment_method_id=none").await["total"],
50_000
);
// Birleşik
let r = report(
&app,
&s,
&format!("?period_id={nov}&group=needs&payment_method_id={nakit}"),
)
.await;
assert_eq!(
(r["total"].as_i64(), r["count"].as_i64()),
(Some(10_000), Some(1))
);
// Hatalı filtreler
for q in [
"?from=2026-11-10&to=2026-11-01",
"?from=2026-02-30",
"?group=xyz",
"?category_id=abc",
] {
let (st, b) = app.get(&format!("/reports/breakdown{q}"), &s).await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{q} → {b}");
}
// Başka hane: kendi verisi (boş); bu hanenin dönemi → 404
let other = app.register("Diger", None).await;
let r = report(&app, &other, "").await;
assert_eq!(r["total"], 0);
assert_eq!(r["groups"].as_array().unwrap().len(), 4);
assert_eq!(r["categories"], json!([]));
let (st, _) = app
.get(&format!("/reports/breakdown?period_id={nov}"), &other)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}
+11
View File
@@ -151,6 +151,17 @@ async fn period_stats_and_total_savings() {
assert_eq!(list[1]["spent"], 42_500);
assert_eq!(list[1]["spent_by_group"]["needs"], 40_000);
assert_eq!(list[1]["spent_by_group"]["culture"], 2_500);
assert_eq!(list[1]["income_total"], 2_000_000);
assert_eq!(list[1]["savings_goal"], 100_000);
assert_eq!(list[1]["carryover_surplus"], 200_000);
assert_eq!(
list[0]["realized_savings"], 700_000,
"Kasım: 10.000 − 3.000"
);
assert_eq!(
list[1]["realized_savings"], 2_157_500,
"20.000 + 2.000 artı − 425 harcanan (hedef düşülmez)"
);
assert_eq!(list[1]["to_savings"], Value::Null);
// limit: en yeni N dönem
+277
View File
@@ -0,0 +1,277 @@
//! CSV dışa / içe aktarma: biçim, deneme → aktarma, satır hataları, tekrar atlama,
//! varlıkta tarih sırasıyla eldekinden fazla satış denetimi.
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, key: &str, value: &str) -> String {
let (_, list) = app.get(path, s).await;
list.as_array()
.unwrap()
.iter()
.find(|c| c[key] == value)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn open(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": start, "end_date": end }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
async fn import(app: &TestApp, s: &Session, kind: &str, csv: &str, commit: bool) -> Value {
let (st, r) = app
.post_text(&format!("/import/{kind}?commit={commit}"), s, csv)
.await;
assert_eq!(st, StatusCode::OK, "{r}");
r
}
fn errors(r: &Value) -> Vec<(u64, String)> {
r["errors"]
.as_array()
.unwrap()
.iter()
.map(|e| {
(
e["line"].as_u64().unwrap(),
e["message"].as_str().unwrap().to_string(),
)
})
.collect()
}
#[tokio::test]
async fn entries_export_and_import() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let market = id_by(&app, &s, "/categories", "name", "Market").await;
let nakit = id_by(&app, &s, "/payment-methods", "name", "Nakit").await;
// Ekim kapalı, Kasım açık
let oct = open(&app, &s, "2026-10-01", "2026-11-01").await;
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
.await;
let nov = open(&app, &s, "2026-11-01", "2026-12-01").await;
app.post(&format!("/periods/{nov}/entries"), &s, json!({ "date": "2026-11-05", "amount": 125_050, "category_id": market, "payment_method_id": nakit, "description": "Kafe; tatlı" })).await;
let (st, ct, csv) = app.get_text("/export/entries.csv", &s).await;
assert_eq!(st, StatusCode::OK);
assert!(ct.starts_with("text/csv"), "{ct}");
assert_eq!(
csv,
"\u{FEFF}Tarih;Tutar;Grup;Kategori;Ödeme türü;Açıklama\r\n05.11.2026;1250,50;Zorunlu;Market;Nakit;\"Kafe; tatlı\"\r\n"
);
// Aynı dosya geri yüklenirse çift kayıt olmaz
let r = import(&app, &s, "entries", &csv, true).await;
assert_eq!(
(
r["total"].as_u64(),
r["valid"].as_u64(),
r["duplicates"].as_u64(),
r["imported"].as_u64()
),
(Some(1), Some(0), Some(1), Some(0))
);
// Sütun sırası serbest, ayrı yazımlar; hatalı satırlar raporlanır
let file = "Açıklama;Kategori;Tutar;Tarih;Grup;Ödeme türü\r\n\
Ekmek;market;45,90;06.11.2026;;\r\n\
x;Market;10;31.02.2026;;\r\n\
x;Market;10;15.09.2026;;\r\n\
x;Market;10;15.10.2026;;\r\n\
x;Uzay;10;06.11.2026;;\r\n\
x;Market;0;06.11.2026;;\r\n\
x;Market;10;06.11.2026;;Kripto\r\n\
x;Market;10;06.11.2026;İstek;\r\n\
Kitaplar;Kitap;1.200;2026-11-07;Kültürel;nakit\r\n\
Kafe; tatlı;Market;1.250,50;05.11.2026;Zorunlu;Nakit\r\n";
// Son satırda tırnaksız ';' → sütunlar kayar: açıkça hata olmalı
let dry = import(&app, &s, "entries", file, false).await;
assert_eq!(dry["total"], 10);
assert_eq!(dry["valid"], 2);
assert_eq!(dry["imported"], 0, "deneme yazmaz");
assert_eq!(
errors(&dry),
vec![
(3, "Tarih geçersiz: 31.02.2026".into()),
(4, "Bu tarih için dönem yok".into()),
(5, "Dönem kapalı (Ekim 2026)".into()),
(6, "Kategori bulunamadı: Uzay".into()),
(7, "Tutar sıfırdan büyük olmalı".into()),
(8, "Ödeme türü bulunamadı: Kripto".into()),
(9, "Kategori bulunamadı: Market".into()),
(11, "Tarih geçersiz: 1.250,50".into()),
]
);
assert_eq!(
dry["preview"][0],
json!(["06.11.2026", "45,90", "Market", "Ekmek"])
);
let (_, before) = app.get(&format!("/periods/{nov}/entries"), &s).await;
assert_eq!(before.as_array().unwrap().len(), 1);
let done = import(&app, &s, "entries", file, true).await;
assert_eq!(done["imported"], 2);
let (_, list) = app.get(&format!("/periods/{nov}/entries"), &s).await;
let list = list.as_array().unwrap();
assert_eq!(list.len(), 3);
let book = list
.iter()
.find(|e| e["description"] == "Kitaplar")
.unwrap();
assert_eq!(book["amount"], 120_000, "1.200 → bin iki yüz");
assert_eq!(book["group"], "culture");
assert_eq!(
book["payment_method_id"],
nakit.as_str(),
"ad büyük/küçük harf duyarsız"
);
// Dosyada 2 aynı satır, veritabanında 1 → biri eklenir
let twice = "Tarih;Tutar;Kategori;Açıklama\r\n06.11.2026;45,90;Market;Ekmek\r\n06.11.2026;45,90;Market;Ekmek\r\n";
let r = import(&app, &s, "entries", twice, true).await;
assert_eq!(
(r["duplicates"].as_u64(), r["imported"].as_u64()),
(Some(1), Some(1))
);
// Biçim hataları
let (st, b) = app
.post_text(
"/import/entries?commit=false",
&s,
"Tarih;Tutar\r\n01.11.2026;5\r\n",
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(b["error"]["message"], "Gerekli sütun eksik: Kategori");
let (st, _) = app.post_text("/import/entries?commit=false", &s, "").await;
assert_eq!(st, StatusCode::BAD_REQUEST);
let (st, _) = app.post_text("/import/bilinmeyen", &s, "a\r\n").await;
assert_eq!(st, StatusCode::NOT_FOUND);
// Başka hane yalnızca kendi verisini dışa aktarır
let other = app.register("Diger", None).await;
let (_, _, csv) = app.get_text("/export/entries.csv", &other).await;
assert_eq!(csv.lines().count(), 1, "yalnızca başlık");
app.cleanup().await;
}
#[tokio::test]
async fn plan_items_roundtrip() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let nov = open(&app, &s, "2026-11-01", "2026-12-01").await;
app.post(
&format!("/periods/{nov}/plan-items"),
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
)
.await;
app.post(
&format!("/periods/{nov}/plan-items"),
&s,
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-11-05" }),
)
.await;
let (_, _, csv) = app.get_text("/export/plan-items.csv", &s).await;
assert_eq!(
csv,
"\u{FEFF}Dönem başlangıcı;Dönem;Tür;Ad;Tutar;Tarih\r\n01.11.2026;Kasım 2026;Gelir;Maaş;45000,00;\r\n01.11.2026;Kasım 2026;Sabit gider;Kira;15000,00;05.11.2026\r\n"
);
// Başka hane: aynı dönemi açıp içe aktarır
let other = app.register("Diger", None).await;
let onov = open(&app, &other, "2026-11-01", "2026-12-01").await;
let with_errors = format!(
"{csv}01.11.2026;Kasım 2026;Prim;Bonus;100,00;\r\n01.01.2026;;Gelir;Eski;1,00;\r\n"
);
let r = import(&app, &other, "plan-items", &with_errors, true).await;
assert_eq!(r["imported"], 2);
assert_eq!(
errors(&r),
vec![
(4, "Tür \"Gelir\" ya da \"Sabit gider\" olmalı: Prim".into()),
(5, "Bu tarih için dönem yok".into()),
]
);
let (_, items) = app
.get(&format!("/periods/{onov}/plan-items"), &other)
.await;
assert_eq!(items.as_array().unwrap().len(), 2);
// Tekrar → atlanır
let r = import(&app, &other, "plan-items", &csv, true).await;
assert_eq!(
(r["duplicates"].as_u64(), r["imported"].as_u64()),
(Some(2), Some(0))
);
app.cleanup().await;
}
#[tokio::test]
async fn asset_transactions_in_date_order() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
// Satış dosyada alımdan önce ama tarihi sonra → geçerli; fazla satış → hata
let file = "Tarih;Varlık kodu;İşlem;Miktar;Birim fiyat;Tutar;Not\r\n\
10.11.2026;XAU_GRAM;Satım;4;;26.000;\r\n\
01.11.2026;XAU_GRAM;Alım;10;6.300;63.000;Kuyumcu\r\n\
12.11.2026;XAU_GRAM;Satış;7;;45.000;\r\n\
02.11.2026;TRY;Alım;;;5.000;\r\n\
02.11.2026;BTC;Alım;1;;1;\r\n\
03.11.2026;USD;Alım;abc;;100;\r\n";
let dry = import(&app, &s, "asset-transactions", file, false).await;
assert_eq!(dry["valid"], 3);
assert_eq!(
errors(&dry),
vec![
(4, "Satış miktarı o tarihte eldeki miktarı aşıyor".into()),
(6, "Varlık bulunamadı: BTC".into()),
(7, "Miktar geçersiz: abc".into()),
]
);
let r = import(&app, &s, "asset-transactions", file, true).await;
assert_eq!(r["imported"], 3);
let (_, assets) = app.get("/assets", &s).await;
let gram = assets
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "XAU_GRAM")
.unwrap();
assert_eq!(gram["quantity_e6"], 6_000_000);
assert_eq!(gram["realized_pl"], 80_000);
let tl = assets
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "TRY")
.unwrap();
assert_eq!(tl["cost"], 500_000);
// Dışa aktar → geri yükle: hepsi tekrar
let (_, _, csv) = app.get_text("/export/asset-transactions.csv", &s).await;
assert!(
csv.contains("01.11.2026;XAU_GRAM;Gram Altın;Alım;10;6300;63000,00;Kuyumcu"),
"{csv}"
);
let r = import(&app, &s, "asset-transactions", &csv, false).await;
assert_eq!(
(r["duplicates"].as_u64(), r["valid"].as_u64()),
(Some(3), Some(0))
);
app.cleanup().await;
}
+11 -1
View File
@@ -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
+10 -1
View File
@@ -1,10 +1,13 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**Faz 9 planı:** Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı. Önce `KAKEIBO_PROMPT.md` §8.8–8.9 (+ `templates/monthly_report.md`) oku; rapor biçimini kullanıcı verecek (açık konu). Kullanıcı şartı: raporlarda **tüm** kategorilerin dağılımı. Adımları PHASES'e yaz, onaylat. Faz 9'da Dokploy adımları da var (§12.2) — canlı kurulum zaten yapıldı.
**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).
- **Faz 8 kararları (kullanıcı, 2026-11-13):** kur kaynakları TCMB (döviz) + xaus.com ons×TCMB USD (altın; çeyrek/yarım/tam has gram ile) + manuel; değerleme **alış kuru**; kapanış birikimi onayla TL varlığına alım (tekrar eklenmez), geçmiş kapanışlar "bekleyen birikimler".
- **Kategori yönetimi ekranı (kullanıcı, 2026-11-13): Faz 10'da** (seçenek b). Faz 8 önce.
@@ -130,3 +133,9 @@
- **Adım 2:** Kurlar: `reqwest` 0.12 (`rustls-tls` → MongoDB ile aynı `ring`; 0.13 `aws-lc-rs` getiriyordu), `quick-xml` (serialize). `services/rate_sources.rs` (saf: `parse_tcmb` ForexBuying ÷ Unit, boş alış atlanır, `decimal_e6` kayan noktasız; `parse_gold` xaus `per_gram_usd`; `gold_rates` has gram katsayıları; testler örnek metinle), `services/rates.rs` (`refresh`: iki kaynak bağımsız, TCMB düşerse DB'deki son USD; gün başına upsert; `spawn_refresher` main'de, `RATES_REFRESH_MINUTES` varsayılan 60 / 0 kapalı; `current`: aggregation kod×(oto|hane) en yeni → en yeni tarih, eşitlikte manuel; `set_manual`/`delete_manual`; `portfolio` + `value_of` yarım yukarı). `GET /portfolio`, `POST /rates`, `DELETE /rates/{id}`. `calendar::today_tr` (UTC+3). Testler sahte axum sunucusuyla (internet yok): `tests/rates.rs` (2) + birim (4). Mutasyon: öncelik, USD yedeği yakalandı; yuvarlama kaçtı → `value_of` birim testi eklendi, artık yakalıyor. Gerçek kaynak denemesi (test DB): TCMB 22 döviz, altın 5 kod (gram 6.253,82, çeyrek 10.024,88 ₺). Backend 145 test. DEPLOY §2 + `.env.example`: `RATES_REFRESH_MINUTES`, konteynerin internete çıkışı gerekir.
- **Adım 3:** Birikim sayfası (`features/savings/`): `page.tsx` (özet: toplam değer + son kur zamanı, maliyet, kâr/zarar `stacked`, gerçekleşen; uyarılar: kur hiç yok / 6 saattir çekilemedi / kursuz varlık; dağılım halka grafiği `allocation-chart.tsx` lazy 6 KB, renkli liste = metin özeti; varlık listesi: elde/geçmişi olanlar + `<details>` "Diğer varlıklar"; `add-currency.tsx`), `asset-page.tsx` (`/birikim/:id`: durum kartları, `RateBox` elle kur gir/kaldır, `TxFormView` `tx-form.tsx` alım/satım: miktar × birim fiyat → tutar önerisi, elle değişen tutar korunur, yeni alımda birim fiyat = güncel kur (kuruşa yuvarlı), TL'de yalnız tutar, satışta eldeki sınırı; geçmiş: düzenle + iki adımlı sil; pasife al/etkinleştir). `lib/decimal.ts` (`parseE6`: virgül ondalık, tek nokta + 3 rakam binlik, `formatE6`, `formatRate`, `valueKurus` BigInt = backend), `lib/savings.ts` (`plRatio`, `formatPlPercent` `-%0,6` ve ≈0 gizli, `isFetchStale` 6 saat, `rateAgeDays`, `splitAssets`, `allocation`, `kindEmoji`, `qty`), `lib/assets.ts` (kancalar, hepsi `['portfolio']` yeniler), `components/decimal-input.tsx`, `formatDateTime`. Frontend 114 test; e2e 37 kontrol (kurlar DB'ye elle, `RATES_REFRESH_MINUTES=0`). Düzeltilen: `%-0,0`, TL satırında tekrar, mobil özet taşması; Tailwind keyfi alt seçici (`[&>*]`) `lg:` ile ezilemedi → sınıflar öğenin kendisine.
- **Adım 4:** Kapanış → birikim. Backend (`services/asset.rs`): `add_period_savings` (yalnız `closed`, `to_savings > 0`, dönem başına tek → 409; TL varlığı pasif olsa da; not "<ad> kapanışı", tarih son gün), `pending_period_savings`, `plan_savings_sync` (transaction öncesi: bağlı kayıt + TL geçmişi denetimi → `SavingsSync::Update/Delete`) + `apply_savings_sync` (kapatma transaction'ı içinde). `PeriodCloseResult.savings_linked`. `GET /savings/pending`, `POST /periods/{id}/to-savings`. `tests/savings_link.rs` (3), mutasyon 3/3. Frontend: kapatma sonucunda `AddToSavings` (onay düğmesi → "Birikime eklendi" + bağlantı; yeniden kapatmada "kayıt güncellendi"), `features/savings/pending.tsx` (Birikimler üstünde), Panel "Toplam birikim" = `portfolio.value_total` (`stats.total_saved` artık arayüzde kullanılmıyor; backend'de duruyor). `useClosePeriod` portföyü de yeniler. Backend 149, frontend 114 test; e2e 14 kontrol. **Faz 8 ✅.**
### 2026-11-13 — Faz 9
- **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.
+9 -1
View File
@@ -13,7 +13,7 @@
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ |
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ⏳ |
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | 🔄 |
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ |
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ |
@@ -77,3 +77,11 @@
- ✅ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli
- ✅ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı
- ✅ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri
## Faz 9 Adımları
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
- ✅ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
- 🔄 4. **Banka / kredi kartı ekstresi içe aktarma** — ✅ 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) · ⏳ 4b arayüz (Excel/CSV okuma, sütun eşleştirme + banka profili, satır gözden geçirme)
- ⏳ 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
@@ -19,14 +19,14 @@ export function AppLayout() {
<Sidebar />
{/* Mobil üst çubuk */}
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden">
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden print:hidden">
<Logo size={28} withText />
</header>
{/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır.
pb-28 → mobilde alt menünün altında içerik kalmasın. */}
<div className="md:pl-64">
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10">
<div className="md:pl-64 print:pl-0">
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10 print:max-w-none print:p-0">
<Outlet />
</main>
</div>
@@ -39,7 +39,7 @@ export function AppLayout() {
function Sidebar() {
return (
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex">
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex print:hidden">
<div className="flex h-16 items-center px-5">
<Logo size={32} withText />
</div>
@@ -78,7 +78,7 @@ function BottomNav() {
return (
<nav
aria-label={t.nav.main}
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden print:hidden"
>
<ul className="grid grid-cols-5">
{bottomNav.map(({ to, label, icon: Icon }) => (
+1 -1
View File
@@ -24,7 +24,7 @@ export function PeriodHeading({ period, actions }: { period: Period; actions?: R
<span
className={cn(
'rounded-full px-2.5 py-0.5 text-xs font-medium',
period.status === 'closed' ? 'bg-muted text-muted-foreground' : 'bg-warning/15',
period.status === 'correcting' ? 'bg-warning/15' : 'bg-muted text-muted-foreground',
)}
>
{t.periods.status[period.status]}
+127
View File
@@ -0,0 +1,127 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { formatCompact, formatMoney, formatPercent } from '@/lib/format'
import { savingsRate } from '@/lib/reports'
import { t } from '@/lib/strings'
import type { PeriodStat } from '@/types/PeriodStat'
const s = t.reports
/** Ham CSS değişkenleri (`--color-*` `@theme inline` yüzünden derlenmiş CSS'te olmayabilir) */
const C = {
income: 'var(--income)',
expense: 'var(--expense)',
primary: 'var(--primary)',
grid: 'var(--border)',
axis: 'var(--muted-foreground)',
}
const tooltipStyle = {
background: 'var(--popover)',
color: 'var(--popover-foreground)',
border: '1px solid var(--border)',
borderRadius: 12,
fontSize: 13,
}
/** Dönem adını eksende kısalt: "Kasım 2026" → "Kas 26" */
const short = (name: string) => {
const m = /^(\S+)\s+(\d{4})$/.exec(name)
return m ? `${m[1].slice(0, 3)} ${m[2].slice(2)}` : name
}
/** Raporlar sayfasının grafikleri (Recharts → ayrı parça) */
export default function ReportChart({
kind,
periods,
}: {
kind: 'periods' | 'rate'
periods: PeriodStat[]
}) {
const data = periods.map((p) => ({
label: short(p.name),
name: p.name,
income: p.income_total,
expense: p.fixed_expense_total + p.spent,
rate: savingsRate(p),
}))
return (
<div className="h-64 sm:h-72" aria-hidden>
<ResponsiveContainer width="100%" height="100%">
{kind === 'periods' ? (
<BarChart data={data} margin={{ top: 5, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={C.grid} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" stroke={C.axis} tick={{ fontSize: 12 }} tickLine={false} />
<YAxis
stroke={C.axis}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(v: number) => formatCompact(v)}
/>
<Tooltip
formatter={(v) => (typeof v === 'number' ? formatMoney(v) : '—')}
labelFormatter={(_, p) => p?.[0]?.payload?.name ?? ''}
contentStyle={tooltipStyle}
/>
<Legend wrapperStyle={{ fontSize: 12 }} itemSorter={null} />
<Bar
dataKey="income"
name={s.income}
fill={C.income}
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
<Bar
dataKey="expense"
name={s.expense}
fill={C.expense}
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
</BarChart>
) : (
<LineChart data={data} margin={{ top: 5, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={C.grid} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" stroke={C.axis} tick={{ fontSize: 12 }} tickLine={false} />
<YAxis
stroke={C.axis}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(v: number) => formatPercent(v, 0)}
/>
<ReferenceLine y={0} stroke={C.axis} />
<Tooltip
formatter={(v) => (typeof v === 'number' ? formatPercent(v) : '—')}
labelFormatter={(_, p) => p?.[0]?.payload?.name ?? ''}
contentStyle={tooltipStyle}
/>
<Line
dataKey="rate"
name={s.rate}
stroke={C.primary}
strokeWidth={3}
dot={{ r: 3, strokeWidth: 2, fill: 'var(--card)' }}
connectNulls
isAnimationActive={false}
/>
</LineChart>
)}
</ResponsiveContainer>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
import { useState, type ReactNode } from 'react'
import { ChevronRight, X } from 'lucide-react'
import { Segmented } from '@/components/segmented'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toIsoDate } from '@/lib/format'
import { GROUPS, type GroupCode } from '@/lib/groups'
import { activeFilterCount, type ReportFilter } from '@/lib/reports'
import { t } from '@/lib/strings'
import type { CatalogItem } from '@/types/CatalogItem'
import type { Period } from '@/types/Period'
const s = t.reports
const select = 'h-11 w-full rounded-lg border bg-background px-3 text-sm'
type Scope = 'all' | 'period' | 'range'
/** Rapor filtreleri. Mobilde katlanır; seçim adrese yazılır (sayfa yenilense de kalır). */
export function Filters({
filter: f,
onChange,
periods,
categories,
methods,
}: {
filter: ReportFilter
onChange: (f: ReportFilter) => void
periods: Period[]
categories: CatalogItem[]
methods: CatalogItem[]
}) {
const [open] = useState(
() => activeFilterCount(f) > 0 || window.matchMedia('(min-width: 768px)').matches,
)
const scope: Scope = f.period ? 'period' : f.from || f.to ? 'range' : 'all'
const count = activeFilterCount(f)
const cats = categories.filter((c) => !f.group || c.group === f.group)
function setScope(next: Scope) {
const rest = { ...f, period: undefined, from: undefined, to: undefined }
if (next === 'period') onChange({ ...rest, period: periods[0]?.id })
else if (next === 'range') {
const today = toIsoDate(new Date())
onChange({ ...rest, from: `${today.slice(0, 4)}-01-01`, to: today })
} else onChange(rest)
}
return (
<details open={open} className="group rounded-2xl border bg-card p-4 sm:p-5 print:hidden">
<summary className="flex min-h-11 cursor-pointer items-center justify-between font-semibold">
{s.filterCount(count)}
<ChevronRight className="size-4 transition-transform group-open:rotate-90" aria-hidden />
</summary>
<div className="mt-3 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">{s.scope}</p>
<Segmented
label={s.scope}
value={scope}
onChange={setScope}
options={[
{ value: 'all', label: s.allTime },
{ value: 'period', label: s.byPeriod },
{ value: 'range', label: s.byRange },
]}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{scope === 'period' && (
<Field label={s.byPeriod} id="f-period">
<select
id="f-period"
name="period"
className={select}
value={f.period ?? ''}
onChange={(e) => onChange({ ...f, period: e.target.value || undefined })}
>
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</Field>
)}
{scope === 'range' && (
<>
<Field label={s.from} id="f-from">
<Input
id="f-from"
name="from"
type="date"
value={f.from ?? ''}
max={f.to}
onChange={(e) => onChange({ ...f, from: e.target.value || undefined })}
/>
</Field>
<Field label={s.to} id="f-to">
<Input
id="f-to"
name="to"
type="date"
value={f.to ?? ''}
min={f.from}
onChange={(e) => onChange({ ...f, to: e.target.value || undefined })}
/>
</Field>
</>
)}
<Field label={s.group} id="f-group">
<select
id="f-group"
name="group"
className={select}
value={f.group ?? ''}
onChange={(e) => {
const group = (e.target.value || undefined) as GroupCode | undefined
// Seçili kategori başka gruptaysa bırak
const keep = categories.find((c) => c.id === f.category)?.group === group
onChange({ ...f, group, category: keep ? f.category : undefined })
}}
>
<option value="">{s.any}</option>
{GROUPS.map((g) => (
<option key={g.code} value={g.code}>
{g.emoji} {g.label}
</option>
))}
</select>
</Field>
<Field label={s.category} id="f-category">
<select
id="f-category"
name="category"
className={select}
value={f.category ?? ''}
onChange={(e) => onChange({ ...f, category: e.target.value || undefined })}
>
<option value="">{s.any}</option>
{cats.map((c) => (
<option key={c.id} value={c.id}>
{c.emoji ? `${c.emoji} ` : ''}
{c.name}
</option>
))}
</select>
</Field>
<Field label={s.method} id="f-method">
<select
id="f-method"
name="method"
className={select}
value={f.method ?? ''}
onChange={(e) => onChange({ ...f, method: e.target.value || undefined })}
>
<option value="">{s.any}</option>
<option value="none">{s.noMethod}</option>
{methods.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</Field>
</div>
{count > 0 && (
<Button variant="ghost" onClick={() => onChange({})}>
<X aria-hidden />
{s.clear}
</Button>
)}
</div>
</details>
)
}
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
return (
<div className="space-y-2">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
{children}
</div>
)
}
+106 -3
View File
@@ -1,11 +1,114 @@
import { ComingSoon, PageHeader } from '@/components/page'
import { Printer } from 'lucide-react'
import { useSearchParams } from 'react-router'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Skeleton } from '@/components/skeleton'
import { Button } from '@/components/ui/button'
import { usePortfolio } from '@/lib/assets'
import { useCategories, usePaymentMethods } from '@/lib/catalog'
import { useCurrentPeriod, usePeriods, useStats } from '@/lib/periods'
import { printSection } from '@/lib/print'
import {
describeFilter,
filterToParams,
parseFilter,
useReportBreakdown,
type ReportFilter,
} from '@/lib/reports'
import { t } from '@/lib/strings'
import { Filters } from './filters'
import {
AssetsReport,
CategoriesReport,
DeviationReport,
GroupsReport,
MethodsReport,
MonthsReport,
PeriodsReport,
RateReport,
} from './sections'
const s = t.reports
/**
* Raporlar: filtreler adreste (`?donem=`, `baslangic`, `bitis`, `grup`, `kategori`, `odeme`).
* Kırılımlar filtreye göre; dönemler arası özet ve varlıklar filtrelerden bağımsız;
* bütçe sapması seçili dönem (yoksa açık / son dönem). Her bölüm CSV + yazdır.
*/
export function ReportsPage() {
const [params, setParams] = useSearchParams()
const filter = parseFilter(params)
const periods = usePeriods()
const current = useCurrentPeriod()
const categories = useCategories(true)
const methods = usePaymentMethods(true)
const stats = useStats(12)
const breakdown = useReportBreakdown(filter)
const portfolio = usePortfolio()
const setFilter = (f: ReportFilter) => setParams(filterToParams(f), { replace: true })
const failed = [periods, categories, methods].find((q) => q.isError)
const devPeriod =
periods.data?.find((p) => p.id === filter.period) ?? current.data ?? periods.data?.[0] ?? null
return (
<>
<PageHeader title={t.pages.reports.title} description={t.pages.reports.description} />
<ComingSoon when="Faz 9'da" />
<PageHeader
title={t.pages.reports.title}
description={t.pages.reports.description}
actions={
<Button variant="outline" className="print:hidden" onClick={() => printSection()}>
<Printer aria-hidden />
{s.printAll}
</Button>
}
/>
{failed?.error ? (
<PageError message={failed.error.message} onRetry={failed.refetch} />
) : !periods.data || !categories.data || !methods.data ? (
<PageLoading />
) : (
<div className="space-y-4">
{/* Yalnız yazdırmada: kağıtta hangi kapsamın basıldığı belli olsun */}
<p className="hidden text-sm print:block">
{describeFilter(filter, {
period: (id) => periods.data.find((p) => p.id === id)?.name,
category: (id) => categories.data.find((c) => c.id === id)?.name,
method: (id) => methods.data.find((m) => m.id === id)?.name,
})}
</p>
<Filters
filter={filter}
onChange={setFilter}
periods={periods.data}
categories={categories.data}
methods={methods.data}
/>
{breakdown.isError ? (
<PageError message={breakdown.error.message} onRetry={breakdown.refetch} />
) : !breakdown.data ? (
<Skeleton className="h-64" />
) : (
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
<GroupsReport b={breakdown.data} />
<MethodsReport b={breakdown.data} methods={methods.data} />
<div className="lg:col-span-2">
<CategoriesReport b={breakdown.data} categories={categories.data} />
</div>
<div className="lg:col-span-2">
<MonthsReport b={breakdown.data} />
</div>
</div>
)}
<DeviationReport period={devPeriod} />
{stats.data && (
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
<PeriodsReport periods={stats.data.periods} />
<RateReport periods={stats.data.periods} />
</div>
)}
{portfolio.data && <AssetsReport p={portfolio.data} />}
</div>
)}
</>
)
}
+145
View File
@@ -0,0 +1,145 @@
import { useRef, type ReactNode } from 'react'
import { Download, Printer } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { downloadCsv, toCsv, type Cell } from '@/lib/csv'
import { formatMoney, formatPercent, toIsoDate } from '@/lib/format'
import { printSection } from '@/lib/print'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
const s = t.reports
export type CsvData = { file: string; headers: string[]; rows: Cell[][] }
/**
* Rapor bölümü: başlık + açıklama + "CSV indir" / "Yazdır" (yalnızca bu bölüm).
* `data-report` → yazdırma stili (lib/print.ts, index.css).
*/
export function ReportSection({
id,
title,
hint,
note,
csv,
children,
}: {
id: string
title: string
hint?: string
/** "Filtrelere göre" / "Filtrelerden bağımsız" */
note?: string
csv?: CsvData
children: ReactNode
}) {
const ref = useRef<HTMLElement>(null)
return (
<section
ref={ref}
data-report
aria-labelledby={`${id}-title`}
className="rounded-2xl border bg-card p-4 sm:p-5 print:rounded-none print:border-0 print:p-0 print:pb-6"
>
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h2 id={`${id}-title`} className="font-semibold">
{title}
</h2>
{(hint || note) && (
<p className="text-sm text-muted-foreground">
{hint}
{hint && note && ' · '}
{note}
</p>
)}
</div>
<div className="flex gap-1 print:hidden">
{csv && (
<Button
variant="ghost"
size="sm"
onClick={() =>
downloadCsv(
`kakeibo-${csv.file}-${toIsoDate(new Date())}`,
toCsv(csv.headers, csv.rows),
)
}
>
<Download aria-hidden />
{s.csv}
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => printSection(ref.current)}>
<Printer aria-hidden />
{s.print}
</Button>
</div>
</div>
{children}
</section>
)
}
/** Tablo + satır içi pay çubuğu (yazdırmada da görünür; grafik kütüphanesi gerekmez) */
export function ShareTable({
rows,
total,
firstHeader,
showCount = true,
}: {
rows: { key: string; label: ReactNode; amount: number; count?: number; color?: string }[]
total: number
firstHeader: string
showCount?: boolean
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="py-2 pr-2 font-medium">{firstHeader}</th>
{showCount && <th className="px-2 py-2 text-right font-medium">{s.count}</th>}
<th className="px-2 py-2 text-right font-medium">{s.share}</th>
<th className="py-2 pl-2 text-right font-medium">{s.amount}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const ratio = total > 0 ? r.amount / total : 0
return (
<tr key={r.key} className="border-b last:border-0">
<td className="py-2 pr-2">
<div className="min-w-32">{r.label}</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className={cn('h-full rounded-full', r.color ?? 'bg-primary')}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</td>
{showCount && <td className="money px-2 py-2 text-right">{r.count ?? ''}</td>}
<td className="money px-2 py-2 text-right text-muted-foreground">
{formatPercent(ratio)}
</td>
<td className="money py-2 pl-2 text-right font-medium whitespace-nowrap">
{formatMoney(r.amount)}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr className="border-t font-semibold">
<td className="py-2 pr-2">{s.total}</td>
{showCount && (
<td className="money px-2 py-2 text-right">
{rows.reduce((a, r) => a + (r.count ?? 0), 0)}
</td>
)}
<td className="px-2 py-2" />
<td className="money py-2 pl-2 text-right whitespace-nowrap">{formatMoney(total)}</td>
</tr>
</tfoot>
</table>
</div>
)
}
+559
View File
@@ -0,0 +1,559 @@
import { lazy, Suspense, type ReactNode } from 'react'
import { PageError, PageLoading } from '@/components/page'
import { Skeleton } from '@/components/skeleton'
import { csvMoney, csvPercent } from '@/lib/csv'
import { formatE6 } from '@/lib/decimal'
import { formatDate, formatDayMonth, formatMoney, formatPercent } from '@/lib/format'
import { group } from '@/lib/groups'
import { useBudget } from '@/lib/periods'
import { deviation, formatMonth, savingsRate } from '@/lib/reports'
import { formatPlPercent, plRatio, qty } from '@/lib/savings'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { CatalogItem } from '@/types/CatalogItem'
import type { Period } from '@/types/Period'
import type { PeriodStat } from '@/types/PeriodStat'
import type { Portfolio } from '@/types/Portfolio'
import type { ReportBreakdown } from '@/types/ReportBreakdown'
import { ReportSection, ShareTable } from './section'
const s = t.reports
const ReportChart = lazy(() => import('./charts'))
const chartFallback = <Skeleton className="h-64 sm:h-72" />
const signed = (kurus: number) => formatMoney(kurus, 'TRY', { signed: true })
const pct = (r: number | null) => (r === null ? '—' : formatPercent(r))
// ---------- Dönemler (filtrelerden bağımsız) ----------
export function PeriodsReport({ periods }: { periods: PeriodStat[] }) {
return (
<ReportSection
id="r-periods"
title={s.periodsTitle}
hint={s.periodsHint}
note={s.unfilteredNote}
csv={{
file: s.file.periods,
headers: [s.period, s.from, s.to, s.income, s.fixed, s.spent, s.realized, `${s.rate} (%)`],
rows: periods.map((p) => [
p.name,
formatDate(p.start_date),
formatDate(p.last_date),
csvMoney(p.income_total),
csvMoney(p.fixed_expense_total),
csvMoney(p.spent),
csvMoney(p.realized_savings),
csvPercent(savingsRate(p)),
]),
}}
>
{periods.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.noPeriods}</p>
) : (
<>
<Suspense fallback={chartFallback}>
<ReportChart kind="periods" periods={periods} />
</Suspense>
<p className="sr-only">{s.periodsSummary(periods.length)}</p>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="py-2 pr-2 font-medium">{s.period}</th>
<th className="px-2 py-2 text-right font-medium">{s.income}</th>
<th className="px-2 py-2 text-right font-medium">{s.expense}</th>
<th className="px-2 py-2 text-right font-medium">{s.realized}</th>
<th className="py-2 pl-2 text-right font-medium">{s.rate}</th>
</tr>
</thead>
<tbody>
{[...periods].reverse().map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2 pr-2 whitespace-nowrap">{p.name}</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(p.income_total)}
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(p.fixed_expense_total + p.spent)}
</td>
<td
className={cn(
'money px-2 py-2 text-right whitespace-nowrap',
p.realized_savings < 0 && 'text-expense',
)}
>
{formatMoney(p.realized_savings)}
</td>
<td className="money py-2 pl-2 text-right">{pct(savingsRate(p))}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</ReportSection>
)
}
export function RateReport({ periods }: { periods: PeriodStat[] }) {
const last = periods.at(-1)
return (
<ReportSection id="r-rate" title={s.rateTitle} hint={s.rateHint} note={s.unfilteredNote}>
{periods.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.noPeriods}</p>
) : (
<>
<Suspense fallback={chartFallback}>
<ReportChart kind="rate" periods={periods} />
</Suspense>
{last && (
<p className="mt-3 text-sm text-muted-foreground">
{s.rateSummary(last.name, pct(savingsRate(last)))}
</p>
)}
</>
)}
</ReportSection>
)
}
// ---------- Kırılımlar (filtrelere göre) ----------
export function GroupsReport({ b }: { b: ReportBreakdown }) {
return (
<ReportSection
id="r-groups"
title={s.groupsTitle}
note={s.filteredNote}
csv={{
file: s.file.groups,
headers: [s.group, s.count, `${s.share} (%)`, s.amount],
rows: b.groups.map((r) => [
group(r.group).label,
r.count,
csvPercent(b.total ? r.total / b.total : 0),
csvMoney(r.total),
]),
}}
>
<ShareTable
firstHeader={s.group}
total={b.total}
rows={b.groups.map((r) => {
const g = group(r.group)
return {
key: r.group,
label: (
<span className={g.className.text}>
{g.emoji} {g.label}
</span>
),
amount: r.total,
count: r.count,
color: g.className.fill,
}
})}
/>
</ReportSection>
)
}
export function CategoriesReport({
b,
categories,
}: {
b: ReportBreakdown
categories: CatalogItem[]
}) {
const name = (id: string) => categories.find((c) => c.id === id)
return (
<ReportSection
id="r-categories"
title={s.categoriesTitle}
hint={s.categoriesHint}
note={s.filteredNote}
csv={{
file: s.file.categories,
headers: [s.category, s.group, s.count, `${s.share} (%)`, s.amount],
rows: b.categories.map((r) => [
name(r.category_id)?.name ?? '—',
group(r.group).label,
r.count,
csvPercent(b.total ? r.total / b.total : 0),
csvMoney(r.total),
]),
}}
>
{b.categories.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.empty}</p>
) : (
<ShareTable
firstHeader={s.category}
total={b.total}
rows={b.categories.map((r) => {
const c = name(r.category_id)
const g = group(r.group)
return {
key: r.category_id,
label: (
<>
{c?.emoji ? `${c.emoji} ` : ''}
{c?.name ?? '—'}{' '}
<span className={cn('text-xs', g.className.text)}>{g.label}</span>
</>
),
amount: r.total,
count: r.count,
color: g.className.fill,
}
})}
/>
)}
</ReportSection>
)
}
export function MethodsReport({ b, methods }: { b: ReportBreakdown; methods: CatalogItem[] }) {
const name = (id: string | null) =>
id ? (methods.find((m) => m.id === id)?.name ?? '—') : s.noMethod
return (
<ReportSection
id="r-methods"
title={s.methodsTitle}
note={s.filteredNote}
csv={{
file: s.file.methods,
headers: [s.method, s.count, `${s.share} (%)`, s.amount],
rows: b.payment_methods.map((r) => [
name(r.payment_method_id),
r.count,
csvPercent(b.total ? r.total / b.total : 0),
csvMoney(r.total),
]),
}}
>
{b.payment_methods.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.empty}</p>
) : (
<ShareTable
firstHeader={s.method}
total={b.total}
rows={b.payment_methods.map((r) => ({
key: r.payment_method_id ?? 'none',
label: name(r.payment_method_id),
amount: r.total,
count: r.count,
}))}
/>
)}
</ReportSection>
)
}
export function MonthsReport({ b }: { b: ReportBreakdown }) {
return (
<ReportSection
id="r-months"
title={s.monthsTitle}
note={s.filteredNote}
csv={{
file: s.file.months,
headers: [s.period, s.count, s.amount],
rows: b.months.map((r) => [formatMonth(r.month), r.count, csvMoney(r.total)]),
}}
>
{b.months.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.empty}</p>
) : (
<ShareTable
firstHeader={s.period}
total={b.total}
rows={b.months.map((r) => ({
key: r.month,
label: formatMonth(r.month),
amount: r.total,
count: r.count,
}))}
/>
)}
</ReportSection>
)
}
// ---------- Bütçe sapması (tek dönem) ----------
export function DeviationReport({ period }: { period: Period | null }) {
if (!period) {
return (
<ReportSection id="r-deviation" title={s.deviationTitle}>
<p className="text-sm text-muted-foreground">{s.noDeviationPeriod}</p>
</ReportSection>
)
}
return <DeviationView period={period} />
}
function DeviationView({ period }: { period: Period }) {
const budget = useBudget(period.id)
const rows = budget.data ? deviation(budget.data) : []
const weeks = period.weeks.map((w, i) => {
const planned = budget.data?.reduce((a, b) => a + (b.weeks[i]?.amount ?? 0), 0) ?? 0
const spent = budget.data?.reduce((a, b) => a + (b.weeks[i]?.spent ?? 0), 0) ?? 0
return { w, planned, spent, diff: planned - spent }
})
const totals = rows.reduce(
(a, r) => ({ planned: a.planned + r.planned, spent: a.spent + r.spent }),
{
planned: 0,
spent: 0,
},
)
return (
<ReportSection
id="r-deviation"
title={s.deviationTitle}
hint={s.deviationHint(period.name)}
csv={{
file: s.file.deviation,
headers: [s.group, s.planned, s.spent, s.diff, `${s.used} (%)`],
rows: [
...rows.map((r) => [
group(r.group).label,
csvMoney(r.planned),
csvMoney(r.spent),
csvMoney(r.diff),
csvPercent(r.used),
]),
...weeks.map(({ w, planned, spent, diff }) => [
s.week(w.index, `${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`),
csvMoney(planned),
csvMoney(spent),
csvMoney(diff),
csvPercent(planned ? spent / planned : null),
]),
],
}}
>
{budget.isError ? (
<PageError message={budget.error.message} onRetry={budget.refetch} />
) : !budget.data ? (
<PageLoading />
) : (
<div className="space-y-4 overflow-x-auto">
<DevTable
first={s.group}
rows={[
...rows.map((r) => {
const g = group(r.group)
return {
key: r.group,
label: (
<span className={g.className.text}>
{g.emoji} {g.label}
</span>
),
...r,
}
}),
]}
total={{ ...totals, diff: totals.planned - totals.spent }}
/>
<h3 className="pt-2 text-sm font-medium">{s.weeksTitle}</h3>
<DevTable
first={s.period}
rows={weeks.map(({ w, planned, spent, diff }) => ({
key: String(w.index),
label: s.week(
w.index,
`${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`,
),
planned,
spent,
diff,
used: planned ? spent / planned : null,
}))}
/>
</div>
)}
</ReportSection>
)
}
function DevTable({
first,
rows,
total,
}: {
first: string
rows: {
key: string
label: ReactNode
planned: number
spent: number
diff: number
used: number | null
}[]
total?: { planned: number; spent: number; diff: number }
}) {
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="py-2 pr-2 font-medium">{first}</th>
<th className="px-2 py-2 text-right font-medium">{s.planned}</th>
<th className="px-2 py-2 text-right font-medium">{s.spent}</th>
<th className="px-2 py-2 text-right font-medium">{s.diff}</th>
<th className="py-2 pl-2 text-right font-medium">{s.used}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.key} className="border-b last:border-0">
<td className="py-2 pr-2 whitespace-nowrap">{r.label}</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(r.planned)}
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">{formatMoney(r.spent)}</td>
<td
className={cn(
'money px-2 py-2 text-right whitespace-nowrap',
r.diff < 0 && 'text-expense',
)}
>
{signed(r.diff)}
</td>
<td className={cn('money py-2 pl-2 text-right', (r.used ?? 0) > 1 && 'text-expense')}>
{pct(r.used)}
</td>
</tr>
))}
</tbody>
{total && (
<tfoot>
<tr className="border-t font-semibold">
<td className="py-2 pr-2">{s.total}</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(total.planned)}
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(total.spent)}
</td>
<td
className={cn(
'money px-2 py-2 text-right whitespace-nowrap',
total.diff < 0 && 'text-expense',
)}
>
{signed(total.diff)}
</td>
<td className="money py-2 pl-2 text-right">
{pct(total.planned ? total.spent / total.planned : null)}
</td>
</tr>
</tfoot>
)}
</table>
)
}
// ---------- Varlıklar ----------
export function AssetsReport({ p }: { p: Portfolio }) {
const items = p.items.filter((i) => i.asset.quantity_e6 > 0 || i.asset.realized_pl !== 0)
return (
<ReportSection
id="r-assets"
title={s.assetsTitle}
hint={s.assetsHint}
note={s.unfilteredNote}
csv={{
file: s.file.assets,
headers: [
s.asset,
s.quantity,
s.cost,
s.value,
s.unrealized,
`${s.unrealized} (%)`,
s.realizedPl,
],
rows: items.map((i) => [
i.asset.name,
`${formatE6(i.asset.quantity_e6)} ${i.asset.unit_label}`,
csvMoney(i.asset.cost),
i.value === null ? '' : csvMoney(i.value),
i.unrealized_pl === null ? '' : csvMoney(i.unrealized_pl),
i.unrealized_pl === null ? '' : csvPercent(plRatio(i.unrealized_pl, i.asset.cost)),
csvMoney(i.asset.realized_pl),
]),
}}
>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.noAssets}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="py-2 pr-2 font-medium">{s.asset}</th>
<th className="px-2 py-2 text-right font-medium">{s.cost}</th>
<th className="px-2 py-2 text-right font-medium">{s.value}</th>
<th className="py-2 pl-2 text-right font-medium">{s.unrealized}</th>
</tr>
</thead>
<tbody>
{items.map((i) => {
const pl = i.unrealized_pl
const r = pl === null ? null : formatPlPercent(plRatio(pl, i.asset.cost))
return (
<tr key={i.asset.id} className="border-b last:border-0">
<td className="py-2 pr-2">
<div className="font-medium">{i.asset.name}</div>
<div className="money text-xs text-muted-foreground">{qty(i.asset)}</div>
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(i.asset.cost)}
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{i.value === null ? '—' : formatMoney(i.value)}
</td>
<td
className={cn(
'money py-2 pl-2 text-right whitespace-nowrap',
(pl ?? 0) > 0 ? 'text-income' : (pl ?? 0) < 0 ? 'text-expense' : '',
)}
>
{pl === null ? '—' : signed(pl)}
{r && <div className="text-xs">{r}</div>}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr className="border-t font-semibold">
<td className="py-2 pr-2">{s.total}</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(p.cost_total)}
</td>
<td className="money px-2 py-2 text-right whitespace-nowrap">
{formatMoney(p.value_total)}
</td>
<td className="money py-2 pl-2 text-right whitespace-nowrap">
{signed(p.unrealized_pl_total)}
</td>
</tr>
</tfoot>
</table>
{p.realized_pl_total !== 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{s.realizedPl}: <span className="money">{signed(p.realized_pl_total)}</span>
</p>
)}
</div>
)}
</ReportSection>
)
}
+4 -2
View File
@@ -52,8 +52,10 @@ export function ClosePeriodPage() {
// Açık dönem ya da `?donem=` ile düzeltmedeki dönem (yeniden kapatma)
<SelectedPeriod empty={empty}>
{(p) =>
p.status === 'closed' ? (
<p className="text-muted-foreground">{s.alreadyClosed}</p>
p.status === 'closed' || p.status === 'archived' ? (
<p className="text-muted-foreground">
{p.status === 'closed' ? s.alreadyClosed : s.archived}
</p>
) : (
<Wizard key={p.id} period={p} onClosed={setResult} />
)
@@ -20,6 +20,14 @@ const s = t.review
*/
export function ClosingSection({ period, periods }: { period: Period; periods: Period[] }) {
if (period.status === 'closed') return <ClosedInfo period={period} periods={periods} />
if (period.status === 'archived') {
return (
<section className="space-y-1 rounded-2xl border bg-card p-5">
<h2 className="font-semibold">{s.archivedTitle}</h2>
<p className="text-sm text-muted-foreground">{s.archivedHint}</p>
</section>
)
}
const correcting = period.status === 'correcting'
return (
<section
@@ -0,0 +1,210 @@
import { useRef, useState } from 'react'
import { CircleCheck, Download, FileUp, Loader2, TriangleAlert } from 'lucide-react'
import { toast } from 'sonner'
import { FormError } from '@/components/form-field'
import { Segmented } from '@/components/segmented'
import { Button } from '@/components/ui/button'
import { Section } from '@/features/planning/plan-read'
import { decodeText } from '@/lib/decode'
import { t } from '@/lib/strings'
import { exportUrl, TRANSFER_KINDS, useImport, type TransferKind } from '@/lib/transfer'
import type { ImportReport } from '@/types/ImportReport'
const s = t.data
const kindLabel: Record<TransferKind, string> = {
entries: s.entries,
'plan-items': s.planItems,
'asset-transactions': s.assetTxs,
}
const MAX_ERRORS = 50
/** Ayarlar → Veri: CSV dışa aktarma ve (denemeli) içe aktarma */
export function DataSection() {
return (
<div className="space-y-4">
<Section title={s.exportTitle}>
<p className="mb-3 text-sm text-muted-foreground">{s.exportHint}</p>
<div className="flex flex-wrap gap-2">
{TRANSFER_KINDS.map((k) => (
<Button key={k} asChild variant="outline">
<a href={exportUrl(k)} download>
<Download aria-hidden />
{kindLabel[k]}
</a>
</Button>
))}
</div>
</Section>
<ImportCsv />
</div>
)
}
function ImportCsv() {
const [kind, setKind] = useState<TransferKind>('entries')
const [file, setFile] = useState<{ name: string; csv: string; encoding: string } | null>(null)
const [report, setReport] = useState<{ r: ImportReport; committed: boolean } | null>(null)
const [error, setError] = useState<string>()
const input = useRef<HTMLInputElement>(null)
const run = useImport()
function reset() {
setFile(null)
setReport(null)
setError(undefined)
if (input.current) input.current.value = ''
}
async function check(f: File, k: TransferKind) {
setError(undefined)
setReport(null)
const { text, encoding } = decodeText(await f.arrayBuffer())
setFile({ name: f.name, csv: text, encoding })
try {
setReport({
r: await run.mutateAsync({ kind: k, csv: text, commit: false }),
committed: false,
})
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
async function commit() {
if (!file) return
try {
const r = await run.mutateAsync({ kind, csv: file.csv, commit: true })
setReport({ r, committed: true })
toast.success(s.done(r.imported))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const r = report?.r
return (
<Section title={s.importTitle}>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{s.importHint}</p>
<Segmented
label={s.importTitle}
value={kind}
onChange={(k) => {
setKind(k)
reset()
}}
options={TRANSFER_KINDS.map((k) => ({ value: k, label: kindLabel[k] }))}
/>
<p className="text-sm">
{s.columns[kind]} <span className="text-muted-foreground">{s.sampleHint}</span>
</p>
<div className="flex flex-wrap items-center gap-3">
<input
ref={input}
type="file"
accept=".csv,text/csv,text/plain"
className="sr-only"
id="import-file"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) void check(f, kind)
}}
/>
<Button asChild variant="outline" disabled={run.isPending}>
<label htmlFor="import-file" className="cursor-pointer">
<FileUp aria-hidden />
{file ? s.another : s.chooseFile}
</label>
</Button>
{file && (
<span className="text-sm text-muted-foreground">
{s.file(file.name, file.encoding)}
</span>
)}
</div>
{run.isPending && (
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden />
{report ? s.importing : s.checking}
</p>
)}
<FormError message={error} />
{r && (
<div className="space-y-3" aria-live="polite">
<ul className="space-y-1 text-sm">
<li>{s.summary(r.total)}</li>
{(report.committed ? r.imported : r.valid) > 0 ? (
<li className="flex items-center gap-1.5 font-medium text-income">
<CircleCheck className="size-4" aria-hidden />
{report.committed ? s.added(r.imported) : s.willAdd(r.valid)}
</li>
) : (
<li className="text-muted-foreground">
{report.committed ? s.added(0) : s.willAdd(0)}
</li>
)}
{r.duplicates > 0 && (
<li className="text-muted-foreground">{s.dupes(r.duplicates)}</li>
)}
{r.errors.length > 0 && (
<li className="flex items-center gap-1.5 font-medium text-expense">
<TriangleAlert className="size-4" aria-hidden />
{s.errorsTitle(r.errors.length)}
</li>
)}
</ul>
{r.errors.length > 0 && (
<ul className="max-h-60 space-y-1 overflow-y-auto rounded-xl bg-expense/5 p-3 text-sm">
{r.errors.slice(0, MAX_ERRORS).map((e) => (
<li key={`${e.line}-${e.message}`}>
<span className="font-medium">{s.line(e.line)}:</span> {e.message}
</li>
))}
{r.errors.length > MAX_ERRORS && (
<li className="text-muted-foreground">
{s.moreErrors(r.errors.length - MAX_ERRORS)}
</li>
)}
</ul>
)}
{!report.committed && r.preview.length > 0 && (
<div>
<h4 className="mb-1 text-sm font-medium">{s.previewTitle}</h4>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<tbody>
{r.preview.map((row, i) => (
<tr key={i} className="border-b last:border-0">
{row.map((c, j) => (
<td key={j} className="px-3 py-1.5 whitespace-nowrap">
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!report.committed &&
(r.valid > 0 ? (
<Button onClick={() => void commit()} disabled={run.isPending}>
{run.isPending && <Loader2 className="animate-spin" aria-hidden />}
{s.commit(r.valid)}
</Button>
) : (
<p className="text-sm text-muted-foreground">{s.nothing}</p>
))}
</div>
)}
</div>
</Section>
)
}
+10 -1
View File
@@ -1,11 +1,20 @@
import { ComingSoon, PageHeader } from '@/components/page'
import { t } from '@/lib/strings'
import { DataSection } from './data-section'
export function SettingsPage() {
return (
<>
<PageHeader title={t.pages.settings.title} description={t.pages.settings.description} />
<ComingSoon when="ilerleyen fazlarda" />
<div className="space-y-6">
<section aria-labelledby="data-title" className="space-y-3">
<h2 id="data-title" className="text-lg font-semibold">
{t.data.title}
</h2>
<DataSection />
</section>
<ComingSoon when="ilerleyen fazlarda (tema, kategoriler, hane)" />
</div>
</>
)
}
+15
View File
@@ -265,3 +265,18 @@
@apply border-border;
}
}
/* Yazdırma (lib/print.ts): tek bölüm seçildiyse diğer rapor bölümleri gizlenir;
kartlar sayfa ortasında bölünmesin; arka plan renkleri (çubuklar) basılsın. */
@media print {
html[data-print-one] main [data-report]:not([data-print-target]) {
display: none;
}
main [data-report] {
break-inside: avoid;
}
* {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
+19 -1
View File
@@ -28,6 +28,15 @@ describe('api', () => {
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
})
it('CSV metnini olduğu gibi gönderir', async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ total: 1 }))
globalThis.fetch = fetchMock
await api.postCsv('/import/entries', 'Tarih;Tutar\r\n')
const [, init] = fetchMock.mock.calls[0]
expect(init.body).toBe('Tarih;Tutar\r\n')
expect(init.headers['Content-Type']).toBe('text/csv; charset=utf-8')
})
it('204 → undefined', async () => {
mockFetch(204)
await expect(api.post('/auth/logout')).resolves.toBeUndefined()
@@ -50,10 +59,19 @@ describe('api', () => {
expect(err.isUnauthorized).toBe(true)
})
it('JSON olmayan hata (ör. proxy 502) → genel Türkçe mesaj', async () => {
it('backend kapalı (vekil 502/503/504) → "sunucu yanıt vermiyor"', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(new Response('Bad Gateway', { status: 502 }))
await expect(api.get('/health')).rejects.toMatchObject({
status: 502,
code: 'unavailable',
message: 'Sunucu şu an yanıt vermiyor. Birkaç saniye sonra tekrar deneyin.',
})
})
it('JSON olmayan diğer hata → genel Türkçe mesaj', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(new Response('oops', { status: 500 }))
await expect(api.get('/health')).rejects.toMatchObject({
status: 500,
message: 'Beklenmeyen bir hata oluştu, lütfen tekrar deneyin.',
})
})
+22 -2
View File
@@ -26,17 +26,31 @@ export class ApiError extends Error {
const NETWORK_MESSAGE = 'Sunucuya ulaşılamadı. İnternet bağlantınızı kontrol edip tekrar deneyin.'
const UNKNOWN_MESSAGE = 'Beklenmeyen bir hata oluştu, lütfen tekrar deneyin.'
/** Vekil (Vite / Traefik) backend'e ulaşamadı: yeniden başlıyor, güncelleniyor ya da kapalı */
const UNAVAILABLE_MESSAGE = 'Sunucu şu an yanıt vermiyor. Birkaç saniye sonra tekrar deneyin.'
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
/** Ham metin gövde (CSV içe aktarma); diğerleri JSON */
class TextBody {
readonly text: string
readonly type: string
constructor(text: string, type: string) {
this.text = text
this.type = type
}
}
async function request<T>(method: Method, path: string, body?: unknown): Promise<T> {
let res: Response
const raw = body instanceof TextBody ? body : null
try {
res = await fetch(`/api${path}`, {
method,
credentials: 'same-origin',
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
headers:
body === undefined ? undefined : { 'Content-Type': raw ? raw.type : 'application/json' },
body: body === undefined ? undefined : raw ? raw.text : JSON.stringify(body),
})
} catch {
throw new ApiError(0, 'network', NETWORK_MESSAGE)
@@ -48,6 +62,9 @@ async function request<T>(method: Method, path: string, body?: unknown): Promise
if (!res.ok) {
const detail = (data as Partial<ApiErrorBody> | null)?.error
if (!detail && [502, 503, 504].includes(res.status)) {
throw new ApiError(res.status, 'unavailable', UNAVAILABLE_MESSAGE)
}
throw new ApiError(res.status, detail?.code ?? 'unknown', detail?.message ?? UNKNOWN_MESSAGE)
}
return data as T
@@ -59,4 +76,7 @@ export const api = {
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
delete: <T = void>(path: string) => request<T>('DELETE', path),
/** CSV metnini gövde olarak gönderir */
postCsv: <T>(path: string, csv: string) =>
request<T>('POST', path, new TextBody(csv, 'text/csv; charset=utf-8')),
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { csvMoney, csvPercent, toCsv } from './csv'
describe('csv', () => {
it('Excel-friendly: BOM, semicolon, CRLF, quoting', () => {
const csv = toCsv(
['Kategori', 'Tutar'],
[
['Market', '1250,50'],
['Dışarıda yemek; kafe', '10,00'],
['"Özel" ad', null],
],
)
expect(csv.charCodeAt(0)).toBe(0xfeff)
expect(csv.slice(1)).toBe(
'Kategori;Tutar\r\nMarket;1250,50\r\n"Dışarıda yemek; kafe";10,00\r\n"""Özel"" ad";\r\n',
)
})
it('money and percent without thousands separators', () => {
expect(csvMoney(125_050)).toBe('1250,50')
expect(csvMoney(100_000_000)).toBe('1000000,00')
expect(csvMoney(-5)).toBe('-0,05')
expect(csvMoney(0)).toBe('0,00')
expect(csvPercent(0.125)).toBe('12,5')
expect(csvPercent(-0.0058)).toBe('-0,6')
expect(csvPercent(null)).toBe('')
})
})
+43
View File
@@ -0,0 +1,43 @@
/**
* CSV dışa aktarma — Türkçe Excel uyumlu (KAKEIBO_PROMPT §8.9):
* UTF-8 **BOM** (Türkçe karakterler), `;` ayırıcı, satır sonu CRLF.
* Tutarlar Excel'in sayı sanması için binlik ayırıcısız ve ondalık virgüllü (`1250,50`).
*/
export type Cell = string | number | null | undefined
const BOM = ''
function quote(v: Cell): string {
if (v === null || v === undefined) return ''
const s = String(v)
return /[;"\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
export function toCsv(headers: string[], rows: Cell[][]): string {
return BOM + [headers, ...rows].map((r) => r.map(quote).join(';')).join('\r\n') + '\r\n'
}
/** Kuruş → `1250,50` (CSV için; binlik ayırıcı yok) */
export function csvMoney(kurus: number): string {
const neg = kurus < 0
const abs = Math.abs(kurus)
return `${neg ? '-' : ''}${Math.floor(abs / 100)},${String(abs % 100).padStart(2, '0')}`
}
/** Oran → `12,5` (yüzde, 1 ondalık; CSV için) */
export function csvPercent(ratio: number | null): string {
return ratio === null ? '' : (Math.round(ratio * 1000) / 10).toString().replace('.', ',')
}
/** Tarayıcıda dosya indirir */
export function downloadCsv(filename: string, csv: string) {
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
const a = document.createElement('a')
a.href = url
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`
document.body.append(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { decodeText } from './decode'
describe('decodeText', () => {
it('UTF-8 (BOM is stripped by the decoder)', () => {
const buf = new TextEncoder().encode('Açıklama;Şeker').buffer
expect(decodeText(buf)).toEqual({ text: 'Açıklama;Şeker', encoding: 'utf-8' })
})
it('Windows-1254 from Turkish Excel', () => {
// "Şeker;Ödeme" Windows-1254 baytları: Ş = 0xDE, Ö = 0xD6
const buf = new Uint8Array([0xde, 0x65, 0x6b, 0x65, 0x72, 0x3b, 0xd6, 0x64, 0x65, 0x6d, 0x65])
.buffer
expect(decodeText(buf)).toEqual({ text: 'Şeker;Ödeme', encoding: 'windows-1254' })
})
})
+11
View File
@@ -0,0 +1,11 @@
/**
* Yüklenen metin dosyasını çözer: önce UTF-8 (BOM'lu ya da değil); geçersizse Excel'in
* Türkçe Windows'ta varsayılan kaydettiği **Windows-1254**. (Türkçe karakterler bozulmasın.)
*/
export function decodeText(buf: ArrayBuffer): { text: string; encoding: 'utf-8' | 'windows-1254' } {
try {
return { text: new TextDecoder('utf-8', { fatal: true }).decode(buf), encoding: 'utf-8' }
} catch {
return { text: new TextDecoder('windows-1254').decode(buf), encoding: 'windows-1254' }
}
}
+1
View File
@@ -23,6 +23,7 @@ function refresh(qc: QueryClient, periodId: string) {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
qc.invalidateQueries({ queryKey: queryKeys.stats }),
qc.invalidateQueries({ queryKey: queryKeys.reports }),
])
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Yazdırma: her zaman açık tema (koyu temada açık renk yazı beyaz kâğıtta okunmaz) ve
* istenirse yalnızca tek bir rapor bölümü. Tarayıcının kendi Ctrl+P'si de aynı kuralı izler.
*/
let installed = false
/** Uygulama açılışında bir kez: yazdırmadan önce açık tema, sonra eski haline */
export function installPrintTheme() {
if (installed || typeof window === 'undefined') return
installed = true
const html = document.documentElement
let wasDark = false
window.addEventListener('beforeprint', () => {
wasDark = html.classList.contains('dark')
html.classList.remove('dark')
})
window.addEventListener('afterprint', () => {
if (wasDark) html.classList.add('dark')
html.removeAttribute('data-print-one')
document.querySelector('[data-print-target]')?.removeAttribute('data-print-target')
})
}
/** Yalnızca `el`'i yazdır (diğer rapor bölümleri gizlenir); `el` yoksa tüm sayfa */
export function printSection(el?: HTMLElement | null) {
if (el) {
document.documentElement.setAttribute('data-print-one', '')
el.setAttribute('data-print-target', '')
}
window.print()
}
+3
View File
@@ -22,6 +22,9 @@ export const queryKeys = {
portfolio: ['portfolio'] as const,
assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const,
pendingSavings: ['portfolio', 'pending'] as const,
/** Rapor kırılımı (`query` = sunucu sorgu metni); kayıt değişince `reports` öneki yenilenir */
reportBreakdown: (query: string) => ['reports', 'breakdown', query] as const,
reports: ['reports'] as const,
period: (id: string) => ['period', id] as const,
planItems: (id: string) => ['period', id, 'plan-items'] as const,
summary: (id: string) => ['period', id, 'summary'] as const,
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import type { GroupBudget } from '@/types/GroupBudget'
import {
activeFilterCount,
describeFilter,
deviation,
filterToParams,
filterToQuery,
formatMonth,
parseFilter,
savingsRate,
} from './reports'
describe('report filter', () => {
it('parses Turkish params; period wins over dates', () => {
const f = parseFilter(new URLSearchParams('donem=p1&baslangic=2026-01-01&grup=needs&odeme=yok'))
expect(f).toEqual({ period: 'p1', group: 'needs', method: 'none' })
expect(filterToQuery(f)).toBe('?period_id=p1&group=needs&payment_method_id=none')
expect(filterToParams(f)).toEqual({ donem: 'p1', grup: 'needs', odeme: 'yok' })
expect(activeFilterCount(f)).toBe(3)
})
it('date range; invalid values ignored', () => {
const f = parseFilter(
new URLSearchParams('baslangic=2026-01-01&bitis=2026-02-30&grup=xyz&kategori=c1'),
)
expect(f).toEqual({ from: '2026-01-01', category: 'c1' })
expect(filterToQuery(f)).toBe('?from=2026-01-01&category_id=c1')
expect(filterToQuery({})).toBe('')
expect(activeFilterCount({})).toBe(0)
})
})
describe('filter description (printed reports)', () => {
const names = {
period: (id: string) => (id === 'p1' ? 'Kasım 2026' : undefined),
category: () => 'Market',
method: () => 'Nakit',
}
it('describes scope and filters', () => {
expect(describeFilter({}, names)).toBe('Kapsam: Tüm zamanlar')
expect(describeFilter({ period: 'p1', group: 'needs', method: 'none' }, names)).toBe(
'Kapsam: Kasım 2026 · Grup: Zorunlu · Ödeme türü: Girilmemiş',
)
expect(describeFilter({ from: '2026-01-01', to: '2026-11-13', category: 'c' }, names)).toBe(
'Kapsam: 01.01.2026 – 13.11.2026 · Kategori: Market',
)
expect(describeFilter({ from: '2026-01-01' }, names)).toBe('Kapsam: 01.01.2026 – …')
})
})
describe('report calculations', () => {
it('savings rate', () => {
expect(savingsRate({ realized_savings: 250, income_total: 1_000 })).toBe(0.25)
expect(savingsRate({ realized_savings: -100, income_total: 1_000 })).toBe(-0.1)
expect(savingsRate({ realized_savings: 0, income_total: 0 })).toBeNull()
expect(formatMonth('2026-11')).toBe('Kasım 2026')
expect(formatMonth('2027-01')).toBe('Ocak 2027')
})
it('budget deviation monthly and weekly', () => {
const wk = (amount: number, spent: number) => ({
index: 1,
start_date: '',
last_date: '',
days: 7,
amount,
is_manual: false,
spent,
})
const budget = [
{
group: 'needs',
monthly: 1_000,
spent: 1_200,
weekly_over: false,
weeks: [wk(300, 100), wk(700, 1_100)],
},
{ group: 'wants', monthly: 0, spent: 50, weekly_over: false, weeks: [wk(0, 50), wk(0, 0)] },
] as GroupBudget[]
expect(deviation(budget)).toEqual([
{ group: 'needs', planned: 1_000, spent: 1_200, diff: -200, used: 1.2 },
{ group: 'wants', planned: 0, spent: 50, diff: -50, used: null },
])
expect(deviation(budget, 2)[0]).toEqual({
group: 'needs',
planned: 700,
spent: 1_100,
diff: -400,
used: 1_100 / 700,
})
})
})
+167
View File
@@ -0,0 +1,167 @@
/**
* Raporlar: filtre (adreste), sunucu kancası ve saf hesaplar.
*
* Adres parametreleri: `donem`, `baslangic`, `bitis`, `grup`, `kategori`, `odeme`
* (`odeme=yok` → ödeme türü girilmemiş). Dönem seçiliyse tarih aralığı yok sayılır.
*/
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import type { GroupBudget } from '@/types/GroupBudget'
import type { PeriodStat } from '@/types/PeriodStat'
import type { ReportBreakdown } from '@/types/ReportBreakdown'
import { api } from './api'
import { formatDate } from './format'
import { group as groupMeta, GROUPS, type GroupCode } from './groups'
import { isIsoDate } from './period-dates'
import { queryKeys } from './query'
export type ReportFilter = {
period?: string
from?: string
to?: string
group?: GroupCode
category?: string
/** `'none'` → girilmemiş */
method?: string
}
const P = {
period: 'donem',
from: 'baslangic',
to: 'bitis',
group: 'grup',
category: 'kategori',
method: 'odeme',
} as const
export function parseFilter(params: URLSearchParams): ReportFilter {
const get = (k: string) => params.get(k)?.trim() || undefined
const f: ReportFilter = {}
const period = get(P.period)
if (period) f.period = period
else {
const from = get(P.from)
const to = get(P.to)
if (from && isIsoDate(from)) f.from = from
if (to && isIsoDate(to)) f.to = to
}
const group = get(P.group)
if (group && GROUPS.some((g) => g.code === group)) f.group = group as GroupCode
const category = get(P.category)
if (category) f.category = category
const method = get(P.method)
if (method) f.method = method === 'yok' ? 'none' : method
return f
}
export function filterToParams(f: ReportFilter): Record<string, string> {
const out: Record<string, string> = {}
if (f.period) out[P.period] = f.period
else {
if (f.from) out[P.from] = f.from
if (f.to) out[P.to] = f.to
}
if (f.group) out[P.group] = f.group
if (f.category) out[P.category] = f.category
if (f.method) out[P.method] = f.method === 'none' ? 'yok' : f.method
return out
}
/** Sunucu sorgusu (`/reports/breakdown?...`) */
export function filterToQuery(f: ReportFilter): string {
const q = new URLSearchParams()
if (f.period) q.set('period_id', f.period)
else {
if (f.from) q.set('from', f.from)
if (f.to) q.set('to', f.to)
}
if (f.group) q.set('group', f.group)
if (f.category) q.set('category_id', f.category)
if (f.method) q.set('payment_method_id', f.method)
const s = q.toString()
return s ? `?${s}` : ''
}
/**
* Filtrenin okunur özeti (yazdırılan raporun başında: kağıtta hangi kapsamın basıldığı belli olsun).
* Örn. `Kapsam: Kasım 2026 · Grup: Zorunlu · Ödeme türü: Girilmemiş`
*/
export function describeFilter(
f: ReportFilter,
names: {
period: (id: string) => string | undefined
category: (id: string) => string | undefined
method: (id: string) => string | undefined
},
): string {
const scope = f.period
? (names.period(f.period) ?? '—')
: f.from || f.to
? `${f.from ? formatDate(f.from) : '…'} – ${f.to ? formatDate(f.to) : '…'}`
: 'Tüm zamanlar'
const parts = [`Kapsam: ${scope}`]
if (f.group) parts.push(`Grup: ${groupMeta(f.group).label}`)
if (f.category) parts.push(`Kategori: ${names.category(f.category) ?? '—'}`)
if (f.method)
parts.push(
`Ödeme türü: ${f.method === 'none' ? 'Girilmemiş' : (names.method(f.method) ?? '—')}`,
)
return parts.join(' · ')
}
export function activeFilterCount(f: ReportFilter): number {
return [f.period || f.from || f.to, f.group, f.category, f.method].filter(Boolean).length
}
export function useReportBreakdown(f: ReportFilter) {
const query = filterToQuery(f)
return useQuery({
queryKey: queryKeys.reportBreakdown(query),
queryFn: () => api.get<ReportBreakdown>(`/reports/breakdown${query}`),
placeholderData: keepPreviousData,
})
}
/** Birikim oranı: gerçekleşen birikim ÷ gelir (gelir yoksa `null`) */
export function savingsRate(
p: Pick<PeriodStat, 'realized_savings' | 'income_total'>,
): number | null {
return p.income_total > 0 ? p.realized_savings / p.income_total : null
}
const monthName = new Intl.DateTimeFormat('tr-TR', {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
})
/** `"2026-11"` → `Kasım 2026` */
export const formatMonth = (ym: string) => monthName.format(new Date(`${ym}-01T00:00:00Z`))
/** Pay (0–1); toplam 0 ise 0 */
export const share = (part: number, total: number) => (total > 0 ? part / total : 0)
export type DeviationRow = {
group: GroupCode
planned: number
spent: number
/** planned − spent (eksi → aşım) */
diff: number
/** spent ÷ planned (plan yoksa `null`) */
used: number | null
}
/** Bütçe sapması: grup başına aylık (ya da `week` verilirse o hafta) planlanan vs gerçekleşen */
export function deviation(budget: GroupBudget[], week?: number): DeviationRow[] {
return budget.map((b) => {
const w = week ? b.weeks[week - 1] : undefined
const planned = week ? (w?.amount ?? 0) : b.monthly
const spent = week ? (w?.spent ?? 0) : b.spent
return {
group: b.group,
planned,
spent,
diff: planned - spent,
used: planned > 0 ? spent / planned : null,
}
})
}
+125 -1
View File
@@ -348,6 +348,9 @@ export const t = {
correctingHint:
'Kayıtları ve planı düzelt, bitince yeniden kapat; sonraki döneme devir güncellenir.',
recloseAction: '🔒 Yeniden Kapat',
archivedTitle: 'Arşiv dönemi',
archivedHint:
'Ekstreden aktarılan geçmiş bir ay. Kayıtları düzeltebilirsin; kapatılmaz, devir üretmez ve bütçe önerilerinde (önceki dönem, son 3 ay) kullanılır.',
fixInLedger: 'Defter’de düzelt',
fixInPlan: 'Plan’da düzelt',
},
@@ -494,9 +497,129 @@ export const t = {
added: (name: string) => `${name} birikimi eklendi`,
},
reports: {
printAll: 'Tümünü yazdır',
print: 'Yazdır',
csv: 'CSV indir',
filters: 'Filtreler',
filterCount: (n: number) => (n ? `Filtreler (${n})` : 'Filtreler'),
scope: 'Kapsam',
allTime: 'Tüm zamanlar',
byPeriod: 'Dönem',
byRange: 'Tarih aralığı',
from: 'Başlangıç',
to: 'Bitiş',
group: 'Grup',
category: 'Kategori',
method: 'Ödeme türü',
any: 'Tümü',
noMethod: 'Girilmemiş',
clear: 'Filtreleri temizle',
filteredNote: 'Filtrelere göre',
unfilteredNote: 'Filtrelerden bağımsız',
empty: 'Bu filtrelerle harcama yok.',
total: 'Toplam',
count: 'Kayıt',
share: 'Pay',
amount: 'Tutar',
// Gelir–gider
periodsTitle: 'Gelir ve gider (dönemler)',
periodsHint: 'Son 12 dönem: gelir ile sabit giderler + harcamaların karşılaştırması.',
period: 'Dönem',
income: 'Gelir',
fixed: 'Sabit gider',
spent: 'Harcanan',
expense: 'Gider',
realized: 'Gerçekleşen birikim',
rate: 'Birikim oranı',
rateTitle: 'Birikim oranı trendi',
rateHint: 'Gelirin yüzde kaçı birikti (gerçekleşen birikim ÷ gelir).',
rateSummary: (name: string, rate: string) => `Son dönem (${name}) birikim oranı: ${rate}.`,
periodsSummary: (n: number) => `${n} dönemin gelir ve gider karşılaştırması aşağıdaki tabloda.`,
noPeriods: 'Henüz dönem yok.',
// Kırılımlar
groupsTitle: 'Gruplara göre harcama',
categoriesTitle: 'Kategorilere göre harcama',
categoriesHint: 'Harcama olan tüm kategoriler, çoktan aza.',
methodsTitle: 'Ödeme türüne göre harcama',
monthsTitle: 'Aylara göre harcama',
// Bütçe sapması
deviationTitle: 'Bütçe sapması',
deviationHint: (name: string) => `${name}: grup bütçeleri ile gerçekleşen harcama.`,
planned: 'Planlanan',
diff: 'Fark',
used: 'Kullanım',
over: 'aşıldı',
weeksTitle: 'Haftalara göre (tüm gruplar)',
week: (n: number, range: string) => `${n}. hafta (${range})`,
noDeviationPeriod: 'Bütçe sapması için bir dönem gerekiyor.',
// Varlıklar
assetsTitle: 'Birikim / varlık performansı',
assetsHint: 'Eldeki varlıkların güncel değeri ve kâr/zarar.',
asset: 'Varlık',
quantity: 'Miktar',
cost: 'Maliyet',
value: 'Güncel değer',
unrealized: 'Kâr / zarar',
realizedPl: 'Gerçekleşen kâr / zarar',
noAssets: 'Henüz birikim kaydı yok.',
file: {
periods: 'gelir-gider',
groups: 'gruplar',
categories: 'kategoriler',
methods: 'odeme-turleri',
months: 'aylar',
deviation: 'butce-sapmasi',
assets: 'varliklar',
},
},
data: {
title: 'Veri',
exportTitle: 'Dışa aktar (CSV)',
exportHint: 'Excel ve LibreOffice’te doğrudan açılır (Türkçe karakterler ve tutarlar doğru).',
entries: 'Harcamalar',
planItems: 'Plan kalemleri',
assetTxs: 'Varlık işlemleri',
importTitle: 'İçe aktar (CSV)',
importHint:
'Önce dosya denenir, hiçbir şey yazılmaz: kaç satırın ekleneceği ve hatalı satırlar gösterilir. Onaylarsan yalnızca geçerli satırlar eklenir. Aynı dosyayı tekrar yüklemek çift kayıt oluşturmaz.',
columns: {
entries:
'Sütunlar: Tarih, Tutar, Kategori (zorunlu) · Grup, Ödeme türü, Açıklama (isteğe bağlı). Tarihin açık ya da düzeltmedeki bir döneme düşmesi gerekir.',
'plan-items':
'Sütunlar: Dönem başlangıcı, Tür (Gelir / Sabit gider), Ad, Tutar (zorunlu) · Tarih (isteğe bağlı).',
'asset-transactions':
'Sütunlar: Tarih, Varlık kodu (ya da Varlık), İşlem (Alım / Satım), Tutar (zorunlu) · Miktar (TL dışında zorunlu), Birim fiyat, Not.',
},
sampleHint: 'Biçim için önce aynı türü dışa aktarıp bakabilirsin.',
chooseFile: 'Dosya seç',
checking: 'Dosya denetleniyor…',
file: (name: string, enc: string) =>
`${name} · ${enc === 'utf-8' ? 'UTF-8' : 'Windows-1254 (Excel Türkçe)'}`,
summary: (total: number) => `${total} satır okundu`,
willAdd: (n: number) => `${n} satır eklenecek`,
added: (n: number) => `${n} satır eklendi`,
dupes: (n: number) => `${n} satır zaten var, atlanacak`,
errorsTitle: (n: number) => `${n} satır hatalı (eklenmeyecek)`,
line: (n: number) => `Satır ${n}`,
moreErrors: (n: number) => `… ve ${n} hata daha`,
previewTitle: 'Eklenecek satırlardan örnek',
commit: (n: number) => `${n} satırı içe aktar`,
importing: 'Aktarılıyor…',
done: (n: number) => `${n} satır içe aktarıldı`,
another: 'Başka dosya seç',
nothing: 'Eklenecek yeni satır yok.',
},
periods: {
picker: 'Dönem',
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
status: {
open: 'Açık',
closed: '🔒 Kapalı',
correcting: '✏️ Düzeltmede',
archived: '🗄️ Arşiv',
},
notFound: 'Dönem bulunamadı. Listeden başka bir dönem seç.',
history: 'Geçmiş dönemler',
toSavings: (x: string) => `Birikime ${x}`,
@@ -540,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.',
+31
View File
@@ -0,0 +1,31 @@
/**
* CSV dışa / içe aktarma. İçe aktarma iki adım: deneme (`commit: false`) → onay (`true`).
* Aktarımdan sonra dönem, rapor ve birikim verileri yenilenir.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { ImportReport } from '@/types/ImportReport'
import { api } from './api'
import { queryKeys } from './query'
export const TRANSFER_KINDS = ['entries', 'plan-items', 'asset-transactions'] as const
export type TransferKind = (typeof TRANSFER_KINDS)[number]
/** İndirme adresi (çerezle; tarayıcı dosya olarak kaydeder) */
export const exportUrl = (kind: TransferKind) => `/api/export/${kind}.csv`
export function useImport() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ kind, csv, commit }: { kind: TransferKind; csv: string; commit: boolean }) =>
api.postCsv<ImportReport>(`/import/${kind}?commit=${commit}`, csv),
onSuccess: (_, v) => {
if (!v.commit) return
void Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.periods }),
qc.invalidateQueries({ queryKey: ['period'] }),
qc.invalidateQueries({ queryKey: queryKeys.portfolio }),
qc.invalidateQueries({ queryKey: queryKeys.reports }),
])
},
})
}
+3
View File
@@ -5,9 +5,12 @@ import { RouterProvider } from 'react-router'
import './index.css'
import { queryClient } from '@/lib/query'
import { Toaster } from '@/components/toaster'
import { installPrintTheme } from '@/lib/print'
import { ThemeProvider } from '@/lib/theme'
import { router } from './router'
installPrintTheme()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
+7
View File
@@ -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, };
+8
View File
@@ -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<BankRowIn>, };
+8
View File
@@ -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<string>, errors: Array<BankRowError>, };
+9
View File
@@ -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<BankPreviewRow>,
/**
* Açılacak arşiv dönemleri
*/
archives: Array<BankArchive>, };
+4
View File
@@ -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<BankRowIn>, };
+23
View File
@@ -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, };
+7
View File
@@ -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, };
+15
View File
@@ -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, };
+7
View File
@@ -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 ImportError = {
/**
* Dosyadaki satır numarası (başlık 1)
*/
line: number, message: string, };
+24
View File
@@ -0,0 +1,24 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ImportError } from "./ImportError";
export type ImportReport = {
/**
* Başlık hariç dolu satır
*/
total: number,
/**
* Eklenebilir (tekrarlar hariç); yazıldıysa yazılan
*/
valid: number,
/**
* Veritabanında zaten olduğu için atlanan
*/
duplicates: number,
/**
* Yazılan (denemede 0)
*/
imported: number, errors: Array<ImportError>,
/**
* İlk geçerli satırlar (gösterime hazır)
*/
preview: Array<Array<string>>, };
+5 -1
View File
@@ -6,11 +6,15 @@ export type PeriodStat = { id: string, name: string, start_date: string,
/**
* Son gün, dahil
*/
last_date: string, status: PeriodStatus, spendable: number, spent: number,
last_date: string, status: PeriodStatus, income_total: number, fixed_expense_total: number, savings_goal: number, carryover_debt: number, carryover_surplus: number, spendable: number, spent: number,
/**
* Harcanan, gruplara göre
*/
spent_by_group: GroupAmounts,
/**
* Gelir − sabit − borç + artı − harcanan (negatifse açık). Birikim oranı = bu ÷ gelir
*/
realized_savings: number,
/**
* Kapanmamışsa `null`
*/
+1 -1
View File
@@ -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";
+27
View File
@@ -0,0 +1,27 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReportCategoryRow } from "./ReportCategoryRow";
import type { ReportGroupRow } from "./ReportGroupRow";
import type { ReportMethodRow } from "./ReportMethodRow";
import type { ReportMonthRow } from "./ReportMonthRow";
export type ReportBreakdown = {
/**
* Kuruş
*/
total: number, count: number,
/**
* 4 grup, sabit sırada
*/
groups: Array<ReportGroupRow>,
/**
* Harcama olan **tüm** kategoriler, çoktan aza
*/
categories: Array<ReportCategoryRow>,
/**
* Ödeme türleri; `null` = girilmemiş
*/
payment_methods: Array<ReportMethodRow>,
/**
* Aylar (`"YYYY-MM"`), eskiden yeniye
*/
months: Array<ReportMonthRow>, };
+4
View File
@@ -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 { Group } from "./Group";
export type ReportCategoryRow = { category_id: string, group: Group, total: number, count: number, };
+4
View File
@@ -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 { Group } from "./Group";
export type ReportGroupRow = { group: Group, total: number, count: number, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ReportMethodRow = { payment_method_id: string | null, total: number, count: number, };
+7
View File
@@ -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 ReportMonthRow = {
/**
* `"YYYY-MM"`
*/
month: string, total: number, count: number, };