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:
@@ -139,3 +139,34 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"ids": ["ID-1", "ID-2", "ID-3", "ID-4"]
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ pub mod common;
|
|||||||
pub mod group;
|
pub mod group;
|
||||||
pub mod household;
|
pub mod household;
|
||||||
pub mod invite;
|
pub mod invite;
|
||||||
|
pub mod period;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|||||||
@@ -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,6 +4,7 @@ mod auth;
|
|||||||
mod catalog;
|
mod catalog;
|
||||||
mod health;
|
mod health;
|
||||||
mod household;
|
mod household;
|
||||||
|
mod period;
|
||||||
mod spa;
|
mod spa;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -59,6 +60,9 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/payment-methods/{id}",
|
"/payment-methods/{id}",
|
||||||
patch(catalog::update_payment_method),
|
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)
|
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
|
||||||
.fallback(|| async { AppError::NotFound });
|
.fallback(|| async { AppError::NotFound });
|
||||||
|
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
|
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
|
||||||
|
|
||||||
pub mod account;
|
pub mod account;
|
||||||
|
pub mod calendar;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod invite;
|
pub mod invite;
|
||||||
|
pub mod period;
|
||||||
pub mod seed;
|
pub mod seed;
|
||||||
|
|||||||
@@ -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(¤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<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ 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
|
## 📌 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`).
|
- **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).
|
- 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.
|
- Ö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).
|
- 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.
|
- 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
|
## ❓ 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ı 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.
|
- **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+).
|
- Ü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 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 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ç).**
|
- **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.
|
||||||
|
|||||||
@@ -44,3 +44,11 @@
|
|||||||
- ✅ 3. Dokploy: Gitea bağlantısı, ortam değişkenleri, alan adı + HTTPS, ilk yayın
|
- ✅ 3. Dokploy: Gitea bağlantısı, ortam değişkenleri, alan adı + HTTPS, ilk yayın
|
||||||
- ✅ 4. Canlıda ilk hesap + kontrol listesi
|
- ✅ 4. Canlıda ilk hesap + kontrol listesi
|
||||||
- ⏳ 5. Yedekleme (mongodump, 14 gün) + geri yükleme denemesi — **ertelendi, Faz 5 bitmeden yapılacak**
|
- ⏳ 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)
|
||||||
|
|||||||
@@ -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>, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
Reference in New Issue
Block a user