48554e0ff6
- Kapatma sonucunda onayla "Birikime ekle" (TL alımı, dönemin son günü) - Birikimler'de "Bekleyen birikimler" listesi - Yeniden kapatmada bağlı kayıt yeni tutara güncellenir (0 → silinir), TL çıkışları aşılırsa reddedilir - Panel "Toplam birikim" artık portföyün güncel değeri Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
8.3 KiB
Rust
281 lines
8.3 KiB
Rust
//! 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 / "Nasıl daha iyi…" |
|
||
//! | POST | `/api/periods/{id}/close` | dönemi kapat: devir + birikime aktarılan (transaction) |
|
||
//! | POST | `/api/periods/{id}/reopen` | **sahip:** kapalı dönemi düzeltmeye aç (`correcting`) |
|
||
//! | POST | `/api/periods/{id}/to-savings` | kapanıştaki birikimi TL varlığına ekle (bkz. `routes/asset.rs`) |
|
||
|
||
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},
|
||
closing,
|
||
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>,
|
||
/// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
|
||
reflection_md: Option<String>,
|
||
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,
|
||
/// "Nasıl daha iyi yapabilirim?" (Markdown)
|
||
reflection_md: Option<String>,
|
||
/// Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null`
|
||
#[ts(type = "number | null")]
|
||
to_savings: Option<i64>,
|
||
/// Kapanış zamanı (ISO 8601)
|
||
closed_at: Option<String>,
|
||
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,
|
||
reflection_md: p.reflection_md,
|
||
to_savings: p.to_savings,
|
||
closed_at: p.closed_at.and_then(|d| d.try_to_rfc3339_string().ok()),
|
||
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,
|
||
reflection_md: req.reflection_md,
|
||
version: req.version,
|
||
},
|
||
)
|
||
.await?;
|
||
Ok(Json(p.into()))
|
||
}
|
||
|
||
// ---------- Kapatma / yeniden açma ----------
|
||
|
||
#[derive(Deserialize, Validate, TS)]
|
||
#[ts(export, rename = "PeriodCloseRequest", optional_fields)]
|
||
pub struct CloseRequest {
|
||
/// Birikime aktarılacak (kuruş); verilmezse gerçekleşen birikimin tamamı. Kalanı artı olarak devreder.
|
||
#[ts(type = "number", optional)]
|
||
to_savings: Option<i64>,
|
||
version: Option<i32>,
|
||
}
|
||
|
||
#[derive(Serialize, TS)]
|
||
#[ts(export, rename = "PeriodCloseResult")]
|
||
pub struct CloseResultDto {
|
||
period: PeriodDto,
|
||
/// Değerlendirmenin 4. sorusu (negatifse açık)
|
||
#[ts(type = "number")]
|
||
realized_savings: i64,
|
||
#[ts(type = "number")]
|
||
to_savings: i64,
|
||
/// Sonraki döneme devreden; yoksa `null`
|
||
carryover: Option<CarryoverDto>,
|
||
/// Birikimlerde bu döneme bağlı kayıt var (yeniden kapatmada yeni tutara güncellendi).
|
||
/// `false` ve `to_savings > 0` → arayüz "Birikime ekle" önerir.
|
||
savings_linked: bool,
|
||
}
|
||
|
||
#[derive(Serialize, TS)]
|
||
#[ts(export, rename = "Carryover")]
|
||
struct CarryoverDto {
|
||
/// `debt` (borç) · `surplus` (artı)
|
||
#[ts(type = "\"debt\" | \"surplus\"")]
|
||
kind: &'static str,
|
||
#[ts(type = "number")]
|
||
amount: i64,
|
||
/// Sonraki dönem henüz açılmadıysa `null` (açılınca bağlanır)
|
||
to_period_id: Option<String>,
|
||
}
|
||
|
||
pub async fn close(
|
||
State(s): State<AppState>,
|
||
auth: AuthUser,
|
||
Path(id): Path<String>,
|
||
ValidJson(req): ValidJson<CloseRequest>,
|
||
) -> AppResult<Json<CloseResultDto>> {
|
||
let c = closing::close(
|
||
&s.db,
|
||
auth.household_id,
|
||
auth.user_id,
|
||
parse_id(&id)?,
|
||
req.to_savings,
|
||
req.version,
|
||
)
|
||
.await?;
|
||
Ok(Json(CloseResultDto {
|
||
period: c.period.into(),
|
||
realized_savings: c.realized_savings,
|
||
to_savings: c.to_savings,
|
||
savings_linked: c.savings_linked,
|
||
carryover: c.carryover.map(|x| CarryoverDto {
|
||
kind: x.kind.as_str(),
|
||
amount: x.amount,
|
||
to_period_id: x.to_period_id.map(|u| u.to_string()),
|
||
}),
|
||
}))
|
||
}
|
||
|
||
pub async fn reopen(
|
||
State(s): State<AppState>,
|
||
auth: AuthUser,
|
||
Path(id): Path<String>,
|
||
) -> AppResult<Json<PeriodDto>> {
|
||
auth.require_owner()?;
|
||
let p = closing::reopen(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?;
|
||
Ok(Json(p.into()))
|
||
}
|