Files
kakeibo/backend/src/routes/period.rs
T
suphicakir 48554e0ff6 feat(birikim): dönem kapanışındaki birikimi varlıklara ekleme
- 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>
2026-11-13 01:27:07 +03:00

281 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()))
}