From b883530f762d1a1571c0b49b85c64681c8ae2013 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 12 Nov 2026 02:56:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(d=C3=B6nem):=20d=C3=B6nem=20a=C3=A7ma,=20a?= =?UTF-8?q?=C3=A7=C4=B1k=20d=C3=B6nem,=20d=C3=BCzenleme=20ve=20haftalar=20?= =?UTF-8?q?(backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/periods: liste, açık dönem, aç, detay, düzenle - Kurallar: tek açık dönem, çakışma yok, bitiş hariç, en fazla 62 gün, kapalı dönem salt okunur - Tarih daraltılırken dışarıda harcama kalırsa engellenir - Haftalar Pazartesi başlangıçlı, kısmi haftalar ayrı (takvim yardımcıları) - Otomatik dönem adı: "Aralık 2026" / "15 Ara 2026 – 14 Oca 2027" - Testler: takvim (4), dönem kuralları (2), entegrasyon (5) Co-Authored-By: Claude Opus 5.5 --- backend/api.http | 31 +++ backend/src/models/mod.rs | 1 + backend/src/models/period.rs | 39 +++ backend/src/routes/mod.rs | 4 + backend/src/routes/period.rs | 186 ++++++++++++++ backend/src/services/calendar.rs | 174 +++++++++++++ backend/src/services/mod.rs | 2 + backend/src/services/period.rs | 295 ++++++++++++++++++++++ backend/tests/periods.rs | 244 ++++++++++++++++++ docs/MEMORY.md | 8 +- docs/PHASES.md | 8 + frontend/src/types/Period.ts | 21 ++ frontend/src/types/PeriodCreateRequest.ts | 19 ++ frontend/src/types/PeriodStatus.ts | 3 + frontend/src/types/PeriodUpdateRequest.ts | 3 + frontend/src/types/PeriodWeek.ts | 11 + 16 files changed, 1048 insertions(+), 1 deletion(-) create mode 100644 backend/src/models/period.rs create mode 100644 backend/src/routes/period.rs create mode 100644 backend/src/services/calendar.rs create mode 100644 backend/src/services/period.rs create mode 100644 backend/tests/periods.rs create mode 100644 frontend/src/types/Period.ts create mode 100644 frontend/src/types/PeriodCreateRequest.ts create mode 100644 frontend/src/types/PeriodStatus.ts create mode 100644 frontend/src/types/PeriodUpdateRequest.ts create mode 100644 frontend/src/types/PeriodWeek.ts diff --git a/backend/api.http b/backend/api.http index d489b14..c4d0eb4 100644 --- a/backend/api.http +++ b/backend/api.http @@ -139,3 +139,34 @@ Content-Type: application/json { "ids": ["ID-1", "ID-2", "ID-3", "ID-4"] } + +# ─────────────────────────── Dönemler ─────────────────────────── +# Bitiş tarihi HARİÇ: Aralık 2026 = "2026-12-01" → "2027-01-01". Hanede tek açık dönem. + +### Açık dönem (yoksa null) +GET {{baseUrl}}/periods/current + +### Tüm dönemler (en yeni başta) +GET {{baseUrl}}/periods + +### Dönem aç — ad boşsa otomatik ("Aralık 2026"); savings_goal kuruş (5.000,00 ₺ = 500000) +POST {{baseUrl}}/periods +Content-Type: application/json + +{ + "start_date": "2026-12-01", + "end_date": "2027-01-01", + "savings_goal": 500000 +} + +### Dönem detayı (haftalar dahil) +GET {{baseUrl}}/periods/BURAYA-ID + +### Dönemi düzenle — "version": son gördüğünüz sürüm (başkası değiştirdiyse 409) +PATCH {{baseUrl}}/periods/BURAYA-ID +Content-Type: application/json + +{ + "savings_goal": 750000, + "version": 1 +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 0673db5..35b6412 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -5,4 +5,5 @@ pub mod common; pub mod group; pub mod household; pub mod invite; +pub mod period; pub mod user; diff --git a/backend/src/models/period.rs b/backend/src/models/period.rs new file mode 100644 index 0000000..04453c3 --- /dev/null +++ b/backend/src/models/period.rs @@ -0,0 +1,39 @@ +//! `periods` koleksiyonu (DATA_MODEL.md §3.4). + +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum PeriodStatus { + /// Girişler yapılabilir. Hanede en fazla bir tane. + Open, + /// Salt okunur (Faz 6: kapanış sihirbazı, sahip yeniden açabilir). + Closed, +} + +impl PeriodStatus { + pub fn as_str(self) -> &'static str { + match self { + PeriodStatus::Open => "open", + PeriodStatus::Closed => "closed", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Period { + #[serde(rename = "_id")] + pub id: Uuid, + pub name: String, + /// `"YYYY-MM-DD"`, dahil + pub start_date: String, + /// `"YYYY-MM-DD"`, **hariç** + pub end_date: String, + pub status: PeriodStatus, + /// Kuruş + pub savings_goal: i64, + pub version: i32, +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index af3f77a..2b9fb61 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -4,6 +4,7 @@ mod auth; mod catalog; mod health; mod household; +mod period; mod spa; use axum::{ @@ -59,6 +60,9 @@ pub fn router(state: AppState) -> Router { "/payment-methods/{id}", patch(catalog::update_payment_method), ) + .route("/periods", get(period::list).post(period::create)) + .route("/periods/current", get(period::current)) + .route("/periods/{id}", get(period::get).patch(period::update)) // Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin) .fallback(|| async { AppError::NotFound }); diff --git a/backend/src/routes/period.rs b/backend/src/routes/period.rs new file mode 100644 index 0000000..397f696 --- /dev/null +++ b/backend/src/routes/period.rs @@ -0,0 +1,186 @@ +//! Dönem uç noktaları (hanenin tüm üyeleri): +//! +//! | Yöntem | Yol | İş | +//! |---|---|---| +//! | GET | `/api/periods` | tüm dönemler (en yeni başta) | +//! | GET | `/api/periods/current` | açık dönem ya da `null` | +//! | POST | `/api/periods` | dönem aç | +//! | GET | `/api/periods/{id}` | dönem + haftalar | +//! | PATCH | `/api/periods/{id}` | ad / tarihler / birikim hedefi | + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use validator::Validate; + +use crate::{ + auth::AuthUser, + error::{AppError, AppResult}, + extract::ValidJson, + models::period::{Period, PeriodStatus}, + services::{ + calendar::{days_between, format_date, parse_date, weeks}, + period::{self, NewPeriod, PeriodChanges}, + }, + state::AppState, +}; + +// ---------- İstek / yanıt ---------- + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "PeriodCreateRequest", optional_fields)] +pub struct CreateRequest { + /// `"YYYY-MM-DD"`, dahil + start_date: String, + /// `"YYYY-MM-DD"`, hariç (ör. Aralık için `2027-01-01`) + end_date: String, + /// Boşsa otomatik ("Aralık 2026") + name: Option, + /// Kuruş + #[ts(type = "number", optional)] + savings_goal: Option, +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "PeriodUpdateRequest", optional_fields)] +pub struct UpdateRequest { + name: Option, + start_date: Option, + end_date: Option, + #[ts(type = "number", optional)] + savings_goal: Option, + version: Option, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "Period")] +pub struct PeriodDto { + id: String, + name: String, + /// Dahil + start_date: String, + /// Hariç (veritabanındaki değer) + end_date: String, + /// Son gün, dahil (gösterim için) + last_date: String, + days: u32, + status: PeriodStatus, + /// Kuruş + #[ts(type = "number")] + savings_goal: i64, + version: i32, + weeks: Vec, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "PeriodWeek")] +struct WeekDto { + /// 1'den başlar + index: u32, + start_date: String, + /// Dahil + last_date: String, + days: u32, +} + +impl From for PeriodDto { + fn from(p: Period) -> Self { + // Veritabanındaki tarihler her zaman doğrulanmış biçimde yazılır + let start = parse_date(&p.start_date).expect("geçerli başlangıç tarihi"); + let end = parse_date(&p.end_date).expect("geçerli bitiş tarihi"); + Self { + id: p.id.to_string(), + name: p.name, + last_date: format_date(end - time::Duration::days(1)), + days: days_between(start, end) as u32, + start_date: p.start_date, + end_date: p.end_date, + status: p.status, + savings_goal: p.savings_goal, + version: p.version, + weeks: weeks(start, end) + .into_iter() + .map(|w| WeekDto { + index: w.index, + start_date: format_date(w.start), + last_date: format_date(w.last), + days: w.days, + }) + .collect(), + } + } +} + +fn parse_id(id: &str) -> AppResult { + Uuid::parse_str(id).map_err(|_| AppError::NotFound) +} + +// ---------- Uç noktalar ---------- + +pub async fn list(State(s): State, auth: AuthUser) -> AppResult>> { + let items = period::list(&s.db, auth.household_id).await?; + Ok(Json(items.into_iter().map(PeriodDto::from).collect())) +} + +pub async fn current( + State(s): State, + auth: AuthUser, +) -> AppResult>> { + let p = period::current(&s.db, auth.household_id).await?; + Ok(Json(p.map(PeriodDto::from))) +} + +pub async fn create( + State(s): State, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, Json)> { + let p = period::create( + &s.db, + auth.household_id, + NewPeriod { + start_date: req.start_date, + end_date: req.end_date, + name: req.name, + savings_goal: req.savings_goal, + }, + ) + .await?; + Ok((StatusCode::CREATED, Json(p.into()))) +} + +pub async fn get( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult> { + let p = period::get(&s.db, auth.household_id, parse_id(&id)?).await?; + Ok(Json(p.into())) +} + +pub async fn update( + State(s): State, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult> { + let p = period::update( + &s.db, + auth.household_id, + parse_id(&id)?, + PeriodChanges { + name: req.name, + start_date: req.start_date, + end_date: req.end_date, + savings_goal: req.savings_goal, + version: req.version, + }, + ) + .await?; + Ok(Json(p.into())) +} diff --git a/backend/src/services/calendar.rs b/backend/src/services/calendar.rs new file mode 100644 index 0000000..34c912e --- /dev/null +++ b/backend/src/services/calendar.rs @@ -0,0 +1,174 @@ +//! Takvim hesapları: tarih okuma/yazma, dönem haftaları, Türkçe ay adları. +//! +//! Tarihler veritabanında `"YYYY-MM-DD"` metni olarak tutulur (DATA_MODEL.md §1.3). +//! Dönem bitişi **hariçtir**: `2026-12-01 – 2027-01-01` = 1–31 Aralık. + +use time::{Date, Duration, Month}; + +const MONTHS: [&str; 12] = [ + "Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim", + "Kasım", "Aralık", +]; +const MONTHS_SHORT: [&str; 12] = [ + "Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara", +]; + +/// `"2026-12-09"` → `Date`. Biçim ya da tarih geçersizse `None` (ör. `2026-02-30`). +pub fn parse_date(s: &str) -> Option { + let b = s.as_bytes(); + if b.len() != 10 || b[4] != b'-' || b[7] != b'-' { + return None; + } + let year: i32 = s[0..4].parse().ok()?; + let month: u8 = s[5..7].parse().ok()?; + let day: u8 = s[8..10].parse().ok()?; + Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok() +} + +/// `Date` → `"2026-12-09"` +pub fn format_date(d: Date) -> String { + format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day()) +} + +/// Bitişi hariç aralığın gün sayısı. +pub fn days_between(start: Date, end_exclusive: Date) -> i64 { + (end_exclusive - start).whole_days() +} + +/// Dönem içindeki bir hafta. Son gün dahildir (`last`), gösterim kolaylığı için. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Week { + /// 1'den başlar + pub index: u32, + pub start: Date, + pub last: Date, + pub days: u32, +} + +/// Dönemi Pazartesi başlangıçlı haftalara böler. +/// Baştaki ve sondaki kısmi haftalar ayrı hafta sayılır (KAKEIBO_PROMPT §6). +pub fn weeks(start: Date, end_exclusive: Date) -> Vec { + let mut out = Vec::new(); + let mut cursor = start; + while cursor < end_exclusive { + // Bir sonraki Pazartesi (ya da dönem sonu, hangisi önce gelirse) + let to_monday = 7 - i64::from(cursor.weekday().number_days_from_monday()); + let next = (cursor + Duration::days(to_monday)).min(end_exclusive); + out.push(Week { + index: out.len() as u32 + 1, + start: cursor, + last: next - Duration::days(1), + days: days_between(cursor, next) as u32, + }); + cursor = next; + } + out +} + +/// Kullanıcı ad vermezse dönem adı: +/// - takvim ayının tamamıysa: `"Aralık 2026"` +/// - değilse: `"15 Ara 2026 – 14 Oca 2027"` +pub fn default_period_name(start: Date, end_exclusive: Date) -> String { + let last = end_exclusive - Duration::days(1); + let full_month = start.day() == 1 + && last.month() == start.month() + && last.year() == start.year() + && (last + Duration::days(1)).day() == 1; + if full_month { + return format!("{} {}", MONTHS[month_idx(start)], start.year()); + } + format!( + "{} {} {} – {} {} {}", + start.day(), + MONTHS_SHORT[month_idx(start)], + start.year(), + last.day(), + MONTHS_SHORT[month_idx(last)], + last.year() + ) +} + +fn month_idx(d: Date) -> usize { + u8::from(d.month()) as usize - 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(s: &str) -> Date { + parse_date(s).unwrap() + } + + #[test] + fn parse_and_format() { + assert_eq!(format_date(d("2026-12-09")), "2026-12-09"); + for bad in [ + "2026-02-30", + "2026-13-01", + "26-12-09", + "2026/12/09", + "", + "2026-12-9", + ] { + assert!(parse_date(bad).is_none(), "{bad} geçersiz olmalı"); + } + assert!(parse_date("2028-02-29").is_some(), "artık yıl"); + } + + #[test] + fn december_2026_weeks() { + // 1 Aralık 2026 Salı → ilk hafta 6 gün (Sal–Paz), son hafta 4 gün (Pzt–Per) + let w = weeks(d("2026-12-01"), d("2027-01-01")); + let summary: Vec<(String, String, u32)> = w + .iter() + .map(|w| (format_date(w.start), format_date(w.last), w.days)) + .collect(); + assert_eq!( + summary, + vec![ + ("2026-12-01".into(), "2026-12-06".into(), 6), + ("2026-12-07".into(), "2026-12-13".into(), 7), + ("2026-12-14".into(), "2026-12-20".into(), 7), + ("2026-12-21".into(), "2026-12-27".into(), 7), + ("2026-12-28".into(), "2026-12-31".into(), 4), + ] + ); + assert_eq!(w.iter().map(|w| w.days).sum::(), 31); + assert_eq!(w.last().unwrap().index, 5); + } + + #[test] + fn weeks_edge_cases() { + // Pazartesi başlayan, tam 2 hafta + let w = weeks(d("2026-11-30"), d("2026-12-14")); + assert_eq!(w.len(), 2); + assert!(w.iter().all(|w| w.days == 7)); + // Tek gün (Pazar) + let w = weeks(d("2026-12-06"), d("2026-12-07")); + assert_eq!((w.len(), w[0].days), (1, 1)); + // Boş aralık + assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty()); + } + + #[test] + fn period_names() { + assert_eq!( + default_period_name(d("2026-12-01"), d("2027-01-01")), + "Aralık 2026" + ); + assert_eq!( + default_period_name(d("2027-02-01"), d("2027-03-01")), + "Şubat 2027" + ); + assert_eq!( + default_period_name(d("2026-12-15"), d("2027-01-15")), + "15 Ara 2026 – 14 Oca 2027" + ); + // Ayın 1'inde başlayıp ay ortasında biten + assert_eq!( + default_period_name(d("2026-12-01"), d("2026-12-16")), + "1 Ara 2026 – 15 Ara 2026" + ); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 9ec4137..58cd1ea 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,6 +1,8 @@ //! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar. pub mod account; +pub mod calendar; pub mod catalog; pub mod invite; +pub mod period; pub mod seed; diff --git a/backend/src/services/period.rs b/backend/src/services/period.rs new file mode 100644 index 0000000..2df356c --- /dev/null +++ b/backend/src/services/period.rs @@ -0,0 +1,295 @@ +//! Dönem iş mantığı (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.4). +//! +//! Kurallar: +//! - Hanede aynı anda en fazla **bir açık** dönem (servis kontrolü + kısmi benzersiz indeks). +//! - Dönemler çakışmaz. Bitiş tarihi hariçtir. +//! - Kapalı dönem salt okunur (yeniden açma: Faz 6, yalnızca sahip). +//! - Tarih aralığı daraltılırken dışarıda harcama kalıyorsa engellenir. + +use mongodb::{ + Database, + bson::{Bson, Document, Uuid, doc}, + options::ReturnDocument, +}; +use time::Date; + +use crate::{ + db::{col, is_duplicate_key}, + error::{AppError, AppResult}, + models::{ + common::{new_meta, now, scope}, + period::{Period, PeriodStatus}, + }, + services::calendar::{days_between, default_period_name, format_date, parse_date}, +}; + +/// En uzun dönem (gün). Aylık defter için ~2 ay pay bırakır. +pub const MAX_DAYS: i64 = 62; +/// Birikim hedefi üst sınırı (kuruş) — yazım hatalarını yakalamak için: 100 milyar ₺ +pub const MAX_AMOUNT: i64 = 10_000_000_000_000; + +pub struct NewPeriod { + pub start_date: String, + pub end_date: String, + pub name: Option, + pub savings_goal: Option, +} + +#[derive(Default)] +pub struct PeriodChanges { + pub name: Option, + pub start_date: Option, + pub end_date: Option, + pub savings_goal: Option, + pub version: Option, +} + +fn coll(db: &Database) -> mongodb::Collection { + db.collection(col::PERIODS) +} + +pub async fn current(db: &Database, household_id: Uuid) -> AppResult> { + let mut f = scope(household_id); + f.insert("status", PeriodStatus::Open.as_str()); + Ok(coll(db).find_one(f).await?) +} + +/// En yeni dönem başta. +pub async fn list(db: &Database, household_id: Uuid) -> AppResult> { + let mut cursor = coll(db) + .find(scope(household_id)) + .sort(doc! { "start_date": -1 }) + .await?; + let mut out = Vec::new(); + while cursor.advance().await? { + out.push(cursor.deserialize_current()?); + } + Ok(out) +} + +pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult { + let mut f = scope(household_id); + f.insert("_id", id); + coll(db).find_one(f).await?.ok_or(AppError::NotFound) +} + +pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResult { + let (start, end) = validate_range(&p.start_date, &p.end_date)?; + let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?; + let name = clean_name(p.name.as_deref(), start, end)?; + + if let Some(open) = current(db, household_id).await? { + return Err(AppError::Conflict(format!( + "Zaten açık bir dönem var ({}). Yeni dönem açmadan önce onu kapatın.", + open.name + ))); + } + ensure_no_overlap(db, household_id, start, end, None).await?; + + // 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) }); + let previous = coll(db) + .find_one(prev_f) + .sort(doc! { "start_date": -1 }) + .await?; + + let mut d = new_meta(household_id); + let id = d.get("_id").cloned().unwrap_or(Bson::Null); + d.insert("name", name); + d.insert("start_date", format_date(start)); + d.insert("end_date", format_date(end)); + d.insert("status", PeriodStatus::Open.as_str()); + d.insert("savings_goal", savings_goal); + d.insert("reflection_md", ""); + d.insert( + "previous_period_id", + previous.map_or(Bson::Null, |p| p.id.into()), + ); + d.insert("closed_at", Bson::Null); + d.insert("closed_by", Bson::Null); + d.insert("reopened_at", Bson::Null); + + match db.collection::(col::PERIODS).insert_one(d).await { + Ok(_) => {} + // Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller + Err(e) if is_duplicate_key(&e) => { + return Err(AppError::Conflict( + "Zaten açık bir dönem var. Sayfayı yenileyin.".into(), + )); + } + Err(e) => return Err(e.into()), + } + + coll(db) + .find_one(doc! { "_id": id }) + .await? + .ok_or_else(|| AppError::Internal("açılan dönem okunamadı".into())) +} + +pub async fn update( + db: &Database, + household_id: Uuid, + id: Uuid, + ch: PeriodChanges, +) -> AppResult { + let current = get(db, household_id, id).await?; + if current.status == PeriodStatus::Closed { + return Err(AppError::Conflict( + "Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(), + )); + } + if ch.version.is_some_and(|v| v != current.version) { + return Err(stale()); + } + + let mut set = doc! { "updated_at": now() }; + + let start_s = ch.start_date.as_deref().unwrap_or(¤t.start_date); + let end_s = ch.end_date.as_deref().unwrap_or(¤t.end_date); + let (start, end) = validate_range(start_s, end_s)?; + let dates_changed = + format_date(start) != current.start_date || format_date(end) != current.end_date; + if dates_changed { + ensure_no_overlap(db, household_id, start, end, Some(id)).await?; + ensure_entries_inside(db, household_id, id, start, end).await?; + set.insert("start_date", format_date(start)); + set.insert("end_date", format_date(end)); + } + + if let Some(name) = ch.name { + set.insert("name", clean_name(Some(&name), start, end)?); + } + if let Some(goal) = ch.savings_goal { + set.insert("savings_goal", validate_amount(goal)?); + } + + let mut f = scope(household_id); + f.insert("_id", id); + f.insert("version", current.version); + coll(db) + .find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } }) + .return_document(ReturnDocument::After) + .await? + .ok_or_else(stale) +} + +// ---------- doğrulamalar ---------- + +fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> { + let bad = |m: &str| AppError::BadRequest(m.into()); + let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?; + let end = parse_date(end).ok_or_else(|| bad("Bitiş tarihi geçersiz"))?; + let days = days_between(start, end); + if days < 1 { + return Err(bad("Bitiş tarihi başlangıçtan sonra olmalı")); + } + if days > MAX_DAYS { + return Err(AppError::BadRequest(format!( + "Dönem en fazla {MAX_DAYS} gün olabilir" + ))); + } + Ok((start, end)) +} + +fn validate_amount(v: i64) -> AppResult { + if !(0..=MAX_AMOUNT).contains(&v) { + return Err(AppError::BadRequest("Tutar geçersiz".into())); + } + Ok(v) +} + +fn clean_name(name: Option<&str>, start: Date, end: Date) -> AppResult { + match name.map(str::trim) { + None | Some("") => Ok(default_period_name(start, end)), + Some(n) if n.chars().count() > 60 => Err(AppError::BadRequest( + "Dönem adı en fazla 60 karakter olabilir".into(), + )), + Some(n) => Ok(n.to_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). +async fn ensure_no_overlap( + db: &Database, + household_id: Uuid, + start: Date, + end: Date, + exclude: Option, +) -> AppResult<()> { + let mut f = scope(household_id); + f.insert("start_date", doc! { "$lt": format_date(end) }); + f.insert("end_date", doc! { "$gt": format_date(start) }); + if let Some(id) = exclude { + f.insert("_id", doc! { "$ne": id }); + } + if let Some(other) = coll(db).find_one(f).await? { + return Err(AppError::Conflict(format!( + "Bu tarihler \"{}\" dönemiyle çakışıyor", + other.name + ))); + } + Ok(()) +} + +/// Dönem daraltılırken dışarıda harcama kalmasın. +async fn ensure_entries_inside( + db: &Database, + household_id: Uuid, + period_id: Uuid, + start: Date, + end: Date, +) -> AppResult<()> { + let mut f = scope(household_id); + f.insert("period_id", period_id); + f.insert( + "$or", + vec![ + doc! { "date": { "$lt": format_date(start) } }, + doc! { "date": { "$gte": format_date(end) } }, + ], + ); + let outside = db + .collection::(col::ENTRIES) + .count_documents(f) + .await?; + if outside > 0 { + return Err(AppError::Conflict(format!( + "Yeni tarih aralığının dışında kalan {outside} harcama var. Önce onları taşıyın ya da silin." + ))); + } + Ok(()) +} + +fn stale() -> AppError { + AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn range_rules() { + assert!(validate_range("2026-12-01", "2027-01-01").is_ok()); + assert!( + validate_range("2026-12-01", "2026-12-01").is_err(), + "boş aralık" + ); + assert!(validate_range("2026-12-10", "2026-12-01").is_err(), "ters"); + assert!( + validate_range("2026-12-01", "2027-02-02").is_err(), + "63 gün" + ); + assert!(validate_range("2026-12-01", "2027-02-01").is_ok(), "62 gün"); + assert!(validate_range("01.12.2026", "2027-01-01").is_err(), "biçim"); + } + + #[test] + fn amount_rules() { + assert!(validate_amount(0).is_ok()); + assert!(validate_amount(-1).is_err()); + assert!(validate_amount(MAX_AMOUNT + 1).is_err()); + } +} diff --git a/backend/tests/periods.rs b/backend/tests/periods.rs new file mode 100644 index 0000000..98e9ce7 --- /dev/null +++ b/backend/tests/periods.rs @@ -0,0 +1,244 @@ +//! Dönem kuralları (KAKEIBO_PROMPT §6). + +mod common; + +use axum::http::StatusCode; +use common::TestApp; +use mongodb::bson::{Document, doc}; +use serde_json::{Value, json}; + +async fn open_december(app: &TestApp, s: &common::Session) -> Value { + let (st, p) = app + .post( + "/periods", + s, + json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": 500000 }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{p}"); + p +} + +#[tokio::test] +async fn open_period_returns_weeks_and_default_name() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + + // Başta açık dönem yok → null + let (st, cur) = app.get("/periods/current", &s).await; + assert_eq!((st, cur), (StatusCode::OK, Value::Null)); + + let p = open_december(&app, &s).await; + assert_eq!(p["name"], "Aralık 2026"); + assert_eq!(p["status"], "open"); + assert_eq!(p["days"], 31); + assert_eq!(p["last_date"], "2026-12-31"); + assert_eq!(p["savings_goal"], 500000); + let days: Vec = p["weeks"] + .as_array() + .unwrap() + .iter() + .map(|w| w["days"].as_i64().unwrap()) + .collect(); + assert_eq!( + days, + vec![6, 7, 7, 7, 4], + "Pazartesi başlangıçlı, kısmi haftalar ayrı" + ); + + let (_, cur) = app.get("/periods/current", &s).await; + assert_eq!(cur["id"], p["id"]); + + app.cleanup().await; +} + +#[tokio::test] +async fn only_one_open_period_and_no_overlap() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let dec = open_december(&app, &s).await; + + // İkinci açık dönem → 409 + let (st, body) = app + .post( + "/periods", + &s, + json!({ "start_date": "2027-01-01", "end_date": "2027-02-01" }), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains("Aralık 2026") + ); + + // Aralık'ı kapat (Faz 6'da sihirbaz yapacak) → çakışan yeni dönem yine 409 + app.db + .collection::("periods") + .update_one( + doc! { "name": "Aralık 2026" }, + doc! { "$set": { "status": "closed" } }, + ) + .await + .unwrap(); + let (st, body) = app + .post( + "/periods", + &s, + json!({ "start_date": "2026-12-20", "end_date": "2027-01-20" }), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{body}"); + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains("çakışıyor") + ); + + // Bitişik dönem (bitiş hariç olduğu için 1 Ocak serbest) → 201 + let (st, jan) = app + .post( + "/periods", + &s, + json!({ "start_date": "2027-01-01", "end_date": "2027-02-01" }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{jan}"); + assert_eq!(jan["name"], "Ocak 2027"); + + // Liste: en yeni başta + let (_, list) = app.get("/periods", &s).await; + let names: Vec<&str> = list + .as_array() + .unwrap() + .iter() + .map(|p| p["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["Ocak 2027", "Aralık 2026"]); + + // Kapalı dönem salt okunur + let (st, _) = app + .patch( + &format!("/periods/{}", dec["id"].as_str().unwrap()), + &s, + json!({ "savings_goal": 1 }), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + + app.cleanup().await; +} + +#[tokio::test] +async fn validation_errors() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + for (start, end) in [ + ("2026-12-10", "2026-12-01"), // ters + ("2026-12-01", "2026-12-01"), // boş + ("2026-02-30", "2026-03-30"), // geçersiz gün + ("01.12.2026", "2027-01-01"), // biçim + ("2026-12-01", "2027-03-01"), // 90 gün + ] { + let (st, _) = app + .post( + "/periods", + &s, + json!({ "start_date": start, "end_date": end }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{start} → {end}"); + } + let (st, _) = app + .post( + "/periods", + &s, + json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": -5 }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "negatif birikim"); + app.cleanup().await; +} + +#[tokio::test] +async fn update_dates_name_goal_and_version() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let p = open_december(&app, &s).await; + let path = format!("/periods/{}", p["id"].as_str().unwrap()); + + // Maaş 15'inde → dönemi kaydır; ad otomatik güncellenmez, istenirse verilir + let (st, upd) = app + .patch( + &path, + &s, + json!({ "start_date": "2026-12-15", "end_date": "2027-01-15", "name": "", "savings_goal": 750000, "version": 1 }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{upd}"); + assert_eq!(upd["name"], "15 Ara 2026 – 14 Oca 2027"); + assert_eq!(upd["savings_goal"], 750000); + assert_eq!(upd["version"], 2); + + // Eski sürümle → 409 + let (st, _) = app + .patch(&path, &s, json!({ "name": "X", "version": 1 })) + .await; + assert_eq!(st, StatusCode::CONFLICT); + + // Aralığın dışında kalacak bir harcama varsa daraltma engellenir + let hid = mongodb::bson::Uuid::parse_str(&s.household_id).unwrap(); + let pid = mongodb::bson::Uuid::parse_str(p["id"].as_str().unwrap()).unwrap(); + app.db + .collection::("entries") + .insert_one(doc! { "household_id": hid, "period_id": pid, "date": "2027-01-10", "deleted_at": null }) + .await + .unwrap(); + let (st, body) = app + .patch(&path, &s, json!({ "end_date": "2027-01-05" })) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{body}"); + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains("1 harcama") + ); + + app.cleanup().await; +} + +#[tokio::test] +async fn periods_are_isolated_between_households() { + let app = TestApp::new().await; + let a = app.register("A", None).await; + let b = app.register("B", None).await; + let p = open_december(&app, &a).await; + let path = format!("/periods/{}", p["id"].as_str().unwrap()); + + // B, A'nın dönemini göremez/değiştiremez; kendi açık dönemi yok + assert_eq!(app.get(&path, &b).await.0, StatusCode::NOT_FOUND); + assert_eq!( + app.patch(&path, &b, json!({ "name": "ele geçirildi" })) + .await + .0, + StatusCode::NOT_FOUND + ); + assert_eq!(app.get("/periods/current", &b).await.1, Value::Null); + assert_eq!(app.get("/periods", &b).await.1, json!([])); + + // A'nın açık dönemi B'nin dönem açmasını engellemez (açık dönem kuralı hane bazlı) + let (st, _) = app + .post( + "/periods", + &b, + json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }), + ) + .await; + assert_eq!(st, StatusCode::CREATED); + + app.cleanup().await; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 83588fa..e312469 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 4 – Başlangıç:** Faz 4 planını sun (dönem açma + aylık planlama + notlar), onay al. Kullanıcı canlıya alma sürecinden yoruldu; geliştirmeye odaklanmak istiyor. +**Faz 4 – Adım 2:** Backend plan kalemleri (`plan_items`: income/expense, ad, tutar kuruş, tarih dönem içinde, sort_order; CRUD + `POST /api/periods/{id}/plan-items/copy-previous`) ve notlar (`notes`: context planning|review, başlık, body_md; CRUD). Kapalı dönemde yazma yok. İzolasyon testleri. ## 📌 Alınan Kararlar - **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`). @@ -36,9 +36,12 @@ - Canlı imaj: node:26-alpine → rust:1.99-slim-trixie → debian:trixie-slim (ca-certificates + curl, uid 10001 `kakeibo`), 174 MB. Docker katman önbelleği için sahte lib/main ile bağımlılık derlemesi; kalıntılar `*kakeibo*` + `.fingerprint` silinir, gerçek kaynak `touch`lanır (yoksa sahte lib kullanılıyordu). - Önbellek: HTML `no-cache`, `/assets/*` 1 yıl immutable, diğer statik 1 saat. Bilinmeyen `/api/*` → JSON 404. - Canlı: domain `kakeibo.ta1sph.website` (DNS → 159.146.61.151, ev dış IP'si olmalı), Gitea Dokploy'a bağlı. Canlı MongoDB = ayrı Dokploy servisi `kakeibo-kakeibomongo-fibzhc` (rs0), kullanıcı `kakeibo_app` (readWrite/kakeibo). +- Dönem: bitiş hariç, en fazla 62 gün, hane başına tek açık, çakışma yok (metin karşılaştırması), kapalı salt okunur, daraltmada dışarıda harcama kalırsa 409. Ad boşsa otomatik (`calendar::default_period_name`). Tüm üyeler açabilir/düzenleyebilir. +- ts-rs: `i64` tutarlar `#[ts(type = "number")]`; isteklerde `Option` için `#[ts(type = "number", optional)]` (yoksa `?` kayboluyor). - Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5, react-router 8, TanStack Query 5. ## ❓ Açık Konular +- **Geliştirme makinesinde Node çalışmıyor (2026-11-12):** CachyOS'ta simdjson 5.1.1 (.so.36) geldi, nodejs 26.13.0-2 hâlâ .so.34 istiyor. Çözüm: `sudo pacman -U /var/cache/pacman/pkg/simdjson-2:5.0.5-1-x86_64.pkg.tar.zst`, sonraki güncellemelerde `--ignore simdjson`. Faz 4 Adım 1'in frontend kontrolleri (tsc/vitest) Node düzelince çalıştırılacak. - **Canlı yedekleme yok** (Faz 3 Adım 5 ertelendi) → gerçek veri girişinden önce, en geç Faz 5 sonunda: günlük mongodump, 14 yedek, geri yükleme testi. - **Canlı MongoDB auth kapalı** (Dokploy replica set betiği keyFile kullanmıyor, özel Command'ı yok sayıyor). İç ağda, port kapalı. Ayrıntı: `docs/DEPLOY.md` §4. - Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+). @@ -78,3 +81,6 @@ - **Adım 1:** tower-http (fs, compression gzip/br, trace). `routes::app()` + `routes/spa.rs` (ServeDir + index.html fallback + cache middleware), API fallback JSON 404, `STATIC_DIR` config, graceful shutdown (SIGTERM/Ctrl+C). `Dockerfile`, `.dockerignore`, `docs/DEPLOY.md` başlatıldı. İmaj yerelde çalıştırıldı (host network, 8090): 10 kontrol + tarayıcı ekran görüntüsü geçti. İlk derlemede Docker önbellek hilesi hatası (sahte lib.rs) bulundu, düzeltildi. - **Adım 2:** Mevcut tek MongoDB (butcem-mongo) Bütçem'e ait → kullanıcı ayrı MongoDB seçti. Dokploy MongoDB (replica set) kuruldu, `kakeibo_app` oluşturuldu, port yayını yok (iki servis de `null`). Sorunlar: kullanıcı şifreyi komuta düz yazıp paylaştı; Dokploy betiği root şifresini servis Args'ına düz yazıyor → iki şifre de mongosh içinde üretilerek yenilendi. passwordPrompt yapıştırma hatası (U_STRINGPREP). Auth açma denemesi: Dokploy özel Command'ı uygulamadı → auth kapalı kabul edildi (açık konu). - **Adım 3–4:** Dokploy Application `kakeibo-app` (iç ad `kakeibo-kakeiboapp-nd5kek`). Sorunlar: push yapılmamıştı (Dockerfile yok); Provider GitHub sekmesinde kalmıştı (`Github Provider not found`, kullanıcı çözdü); Traefik 404 (kullanıcı çözdü, büyük olasılıkla domain sonrası redeploy). **Yayında:** https://kakeibo.ta1sph.website — health ok, Let's Encrypt, ilk hesap açıldı. **Faz 3 tamamlandı (yedekleme hariç).** + +### 2026-11-12 — Faz 4 +- **Adım 1:** `services/calendar.rs` (parse/format, `weeks` Pazartesi başlangıçlı + kısmi haftalar, Türkçe ay adları; 4 test), `models/period.rs`, `services/period.rs` (2 test), `routes/period.rs` (list/current/create/get/patch; DTO'da `last_date`, `days`, `weeks`). `tests/periods.rs` (5) — çakışma kontrolü bilerek bozulunca test yakaladı. Backend toplam 58 test. diff --git a/docs/PHASES.md b/docs/PHASES.md index b5c06dc..a9e366b 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -44,3 +44,11 @@ - ✅ 3. Dokploy: Gitea bağlantısı, ortam değişkenleri, alan adı + HTTPS, ilk yayın - ✅ 4. Canlıda ilk hesap + kontrol listesi - ⏳ 5. Yedekleme (mongodump, 14 gün) + geri yükleme denemesi — **ertelendi, Faz 5 bitmeden yapılacak** + +## Faz 4 Adımları +- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler +- ⏳ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar +- ⏳ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar) +- ⏳ 4. Frontend: ilk dönem sihirbazı +- ⏳ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı) +- ⏳ 6. Frontend: notlar (Markdown, düzenle/önizle) diff --git a/frontend/src/types/Period.ts b/frontend/src/types/Period.ts new file mode 100644 index 0000000..6149d99 --- /dev/null +++ b/frontend/src/types/Period.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PeriodStatus } from "./PeriodStatus"; +import type { PeriodWeek } from "./PeriodWeek"; + +export type Period = { id: string, name: string, +/** + * Dahil + */ +start_date: string, +/** + * Hariç (veritabanındaki değer) + */ +end_date: string, +/** + * Son gün, dahil (gösterim için) + */ +last_date: string, days: number, status: PeriodStatus, +/** + * Kuruş + */ +savings_goal: number, version: number, weeks: Array, }; diff --git a/frontend/src/types/PeriodCreateRequest.ts b/frontend/src/types/PeriodCreateRequest.ts new file mode 100644 index 0000000..f68fbd2 --- /dev/null +++ b/frontend/src/types/PeriodCreateRequest.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PeriodCreateRequest = { +/** + * `"YYYY-MM-DD"`, dahil + */ +start_date: string, +/** + * `"YYYY-MM-DD"`, hariç (ör. Aralık için `2027-01-01`) + */ +end_date: string, +/** + * Boşsa otomatik ("Aralık 2026") + */ +name?: string, +/** + * Kuruş + */ +savings_goal?: number, }; diff --git a/frontend/src/types/PeriodStatus.ts b/frontend/src/types/PeriodStatus.ts new file mode 100644 index 0000000..5a9ee91 --- /dev/null +++ b/frontend/src/types/PeriodStatus.ts @@ -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 PeriodStatus = "open" | "closed"; diff --git a/frontend/src/types/PeriodUpdateRequest.ts b/frontend/src/types/PeriodUpdateRequest.ts new file mode 100644 index 0000000..301faa0 --- /dev/null +++ b/frontend/src/types/PeriodUpdateRequest.ts @@ -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 PeriodUpdateRequest = { name?: string, start_date?: string, end_date?: string, savings_goal?: number, version?: number, }; diff --git a/frontend/src/types/PeriodWeek.ts b/frontend/src/types/PeriodWeek.ts new file mode 100644 index 0000000..86b1916 --- /dev/null +++ b/frontend/src/types/PeriodWeek.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PeriodWeek = { +/** + * 1'den başlar + */ +index: number, start_date: string, +/** + * Dahil + */ +last_date: string, days: number, };