feat(dönem): dönem açma, açık dönem, düzenleme ve haftalar (backend)

- /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 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 02:56:13 +03:00
parent a2b4119f3e
commit b883530f76
16 changed files with 1048 additions and 1 deletions
+31
View File
@@ -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
}
+1
View File
@@ -5,4 +5,5 @@ pub mod common;
pub mod group;
pub mod household;
pub mod invite;
pub mod period;
pub mod user;
+39
View File
@@ -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,
}
+4
View File
@@ -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 });
+186
View File
@@ -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<String>,
/// Kuruş
#[ts(type = "number", optional)]
savings_goal: Option<i64>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PeriodUpdateRequest", optional_fields)]
pub struct UpdateRequest {
name: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
#[ts(type = "number", optional)]
savings_goal: Option<i64>,
version: Option<i32>,
}
#[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<WeekDto>,
}
#[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<Period> 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> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
// ---------- Uç noktalar ----------
pub async fn list(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<Vec<PeriodDto>>> {
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<AppState>,
auth: AuthUser,
) -> AppResult<Json<Option<PeriodDto>>> {
let p = period::current(&s.db, auth.household_id).await?;
Ok(Json(p.map(PeriodDto::from)))
}
pub async fn create(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<PeriodDto>)> {
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<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<PeriodDto>> {
let p = period::get(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(Json(p.into()))
}
pub async fn update(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<PeriodDto>> {
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()))
}
+174
View File
@@ -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<Date> {
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<Week> {
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::<u32>(), 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"
);
}
}
+2
View File
@@ -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;
+295
View File
@@ -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<String>,
pub savings_goal: Option<i64>,
}
#[derive(Default)]
pub struct PeriodChanges {
pub name: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub savings_goal: Option<i64>,
pub version: Option<i32>,
}
fn coll(db: &Database) -> mongodb::Collection<Period> {
db.collection(col::PERIODS)
}
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<Option<Period>> {
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<Vec<Period>> {
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<Period> {
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<Period> {
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::<Document>(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<Period> {
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(&current.start_date);
let end_s = ch.end_date.as_deref().unwrap_or(&current.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<i64> {
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<String> {
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<Uuid>,
) -> 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::<Document>(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());
}
}
+244
View File
@@ -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<i64> = 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::<Document>("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::<Document>("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;
}
+7 -1
View File
@@ -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<i64>` 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.
+8
View File
@@ -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)
+21
View File
@@ -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<PeriodWeek>, };
+19
View File
@@ -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, };
+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 PeriodStatus = "open" | "closed";
@@ -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, };
+11
View File
@@ -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, };