feat(dönem): dönem kapatma, devir ve düzeltme için yeniden açma (backend)
- POST /periods/{id}/close: tek transaction; açık → borç, artı → birikim + devir
- Devir sonraki dönem açılınca ona bağlanır
- POST /periods/{id}/reopen (sahip): düzeltme modu, yeni dönem açık kalır
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -313,3 +313,14 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"reflection_md": "- Dışarıda yemeği haftada 1'e indir\n- Market listesiyle alışveriş"
|
"reflection_md": "- Dışarıda yemeği haftada 1'e indir\n- Market listesiyle alışveriş"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
### Dönemi kapat: to_savings verilmezse gerçekleşen birikimin tamamı birikime; kalanı artı, açık varsa borç devreder
|
||||||
|
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/close
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"to_savings": 500000
|
||||||
|
}
|
||||||
|
|
||||||
|
### Kapalı dönemi düzeltmeye aç (yalnızca hane sahibi) → status: correcting; düzeltince yeniden "close"
|
||||||
|
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/reopen
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
|
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
|
||||||
|
|
||||||
use mongodb::bson::Uuid;
|
use mongodb::bson::{DateTime, Uuid};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
|
||||||
@@ -10,8 +10,12 @@ use ts_rs::TS;
|
|||||||
pub enum PeriodStatus {
|
pub enum PeriodStatus {
|
||||||
/// Girişler yapılabilir. Hanede en fazla bir tane.
|
/// Girişler yapılabilir. Hanede en fazla bir tane.
|
||||||
Open,
|
Open,
|
||||||
/// Salt okunur (Faz 6: kapanış sihirbazı, sahip yeniden açabilir).
|
/// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
|
||||||
Closed,
|
Closed,
|
||||||
|
/// Düzeltmede: kapanmış dönem sahip tarafından düzeltme için açıldı.
|
||||||
|
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
|
||||||
|
/// "Yeniden kapat" ile devir yeniden hesaplanır.
|
||||||
|
Correcting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PeriodStatus {
|
impl PeriodStatus {
|
||||||
@@ -19,8 +23,14 @@ impl PeriodStatus {
|
|||||||
match self {
|
match self {
|
||||||
PeriodStatus::Open => "open",
|
PeriodStatus::Open => "open",
|
||||||
PeriodStatus::Closed => "closed",
|
PeriodStatus::Closed => "closed",
|
||||||
|
PeriodStatus::Correcting => "correcting",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kayıt eklenip değiştirilebilir mi?
|
||||||
|
pub fn is_writable(self) -> bool {
|
||||||
|
self != PeriodStatus::Closed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -38,5 +48,10 @@ pub struct Period {
|
|||||||
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
|
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub reflection_md: Option<String>,
|
pub reflection_md: Option<String>,
|
||||||
|
/// Kapanışta birikime aktarılan tutar (kuruş). Faz 8'de varlık hareketine dönüşecek.
|
||||||
|
#[serde(default)]
|
||||||
|
pub to_savings: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub closed_at: Option<DateTime>,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/periods", get(period::list).post(period::create))
|
.route("/periods", get(period::list).post(period::create))
|
||||||
.route("/periods/current", get(period::current))
|
.route("/periods/current", get(period::current))
|
||||||
.route("/periods/{id}", get(period::get).patch(period::update))
|
.route("/periods/{id}", get(period::get).patch(period::update))
|
||||||
|
.route("/periods/{id}/close", post(period::close))
|
||||||
|
.route("/periods/{id}/reopen", post(period::reopen))
|
||||||
.route(
|
.route(
|
||||||
"/periods/{id}/plan-items",
|
"/periods/{id}/plan-items",
|
||||||
get(plan::list).post(plan::create),
|
get(plan::list).post(plan::create),
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
//! | GET | `/api/periods/current` | açık dönem ya da `null` |
|
//! | GET | `/api/periods/current` | açık dönem ya da `null` |
|
||||||
//! | POST | `/api/periods` | dönem aç |
|
//! | POST | `/api/periods` | dönem aç |
|
||||||
//! | GET | `/api/periods/{id}` | dönem + haftalar |
|
//! | GET | `/api/periods/{id}` | dönem + haftalar |
|
||||||
//! | PATCH | `/api/periods/{id}` | ad / tarihler / birikim hedefi |
|
//! | 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`) |
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -25,6 +27,7 @@ use crate::{
|
|||||||
models::period::{Period, PeriodStatus},
|
models::period::{Period, PeriodStatus},
|
||||||
services::{
|
services::{
|
||||||
calendar::{days_between, format_date, parse_date, weeks},
|
calendar::{days_between, format_date, parse_date, weeks},
|
||||||
|
closing,
|
||||||
period::{self, NewPeriod, PeriodChanges},
|
period::{self, NewPeriod, PeriodChanges},
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
@@ -77,6 +80,11 @@ pub struct PeriodDto {
|
|||||||
savings_goal: i64,
|
savings_goal: i64,
|
||||||
/// "Nasıl daha iyi yapabilirim?" (Markdown)
|
/// "Nasıl daha iyi yapabilirim?" (Markdown)
|
||||||
reflection_md: Option<String>,
|
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,
|
version: i32,
|
||||||
weeks: Vec<WeekDto>,
|
weeks: Vec<WeekDto>,
|
||||||
}
|
}
|
||||||
@@ -107,6 +115,8 @@ impl From<Period> for PeriodDto {
|
|||||||
status: p.status,
|
status: p.status,
|
||||||
savings_goal: p.savings_goal,
|
savings_goal: p.savings_goal,
|
||||||
reflection_md: p.reflection_md,
|
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,
|
version: p.version,
|
||||||
weeks: weeks(start, end)
|
weeks: weeks(start, end)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -190,3 +200,76 @@ pub async fn update(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Json(p.into()))
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
//! Dönem kapatma ve yeniden açma (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.10, §6).
|
||||||
|
//!
|
||||||
|
//! Kapatma (tek transaction):
|
||||||
|
//! 1. Dönem `closed` olur (`closed_at`, `closed_by`, `to_savings`).
|
||||||
|
//! 2. Bu dönemden önceki devirler (yeniden kapatmada) soft delete edilir.
|
||||||
|
//! 3. Gerçekleşen birikim (değerlendirmenin 4. sorusu) dağıtılır:
|
||||||
|
//! - < 0 → açık **borç** olarak devreder (`debt`).
|
||||||
|
//! - ≥ 0 → `to_savings` kadarı birikime (varsayılan: tamamı), kalanı **artı** olarak devreder (`surplus`).
|
||||||
|
//! 4. Devir sonraki döneme bağlanır; sonraki dönem henüz yoksa `to_period_id: null` kalır
|
||||||
|
//! ve o dönem açılınca bağlanır (`period::create`).
|
||||||
|
//!
|
||||||
|
//! Yeniden açma (yalnızca sahip): dönem `correcting` olur, yeni açık dönem varsa o da açık kalır.
|
||||||
|
//! Mevcut devir, "yeniden kapat" denene kadar yerinde kalır (sonraki dönemin rakamları oynamaz).
|
||||||
|
//! Ardından kapanmış dönem olan bir dönem açılamaz: sonraki devirler zincirleme bozulurdu.
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{Bson, Document, Uuid, doc},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{
|
||||||
|
common::{new_meta, now, scope},
|
||||||
|
period::{Period, PeriodStatus},
|
||||||
|
},
|
||||||
|
services::{period, review},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CarryKind {
|
||||||
|
Debt,
|
||||||
|
Surplus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CarryKind {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
CarryKind::Debt => "debt",
|
||||||
|
CarryKind::Surplus => "surplus",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Carry {
|
||||||
|
pub kind: CarryKind,
|
||||||
|
pub amount: i64,
|
||||||
|
pub to_period_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Closing {
|
||||||
|
pub period: Period,
|
||||||
|
pub realized_savings: i64,
|
||||||
|
pub to_savings: i64,
|
||||||
|
pub carryover: Option<Carry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime.
|
||||||
|
pub fn settle(
|
||||||
|
realized: i64,
|
||||||
|
to_savings: Option<i64>,
|
||||||
|
) -> AppResult<(i64, Option<(CarryKind, i64)>)> {
|
||||||
|
if realized < 0 {
|
||||||
|
if to_savings.unwrap_or(0) != 0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Bu dönemde birikim yok; açık, borç olarak sonraki döneme devreder".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok((0, Some((CarryKind::Debt, -realized))));
|
||||||
|
}
|
||||||
|
let t = to_savings.unwrap_or(realized);
|
||||||
|
if !(0..=realized).contains(&t) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Birikime aktarılacak tutar 0 ile gerçekleşen birikim arasında olmalı".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let surplus = realized - t;
|
||||||
|
Ok((t, (surplus > 0).then_some((CarryKind::Surplus, surplus))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
to_savings: Option<i64>,
|
||||||
|
version: Option<i32>,
|
||||||
|
) -> AppResult<Closing> {
|
||||||
|
let p = period::get(db, household_id, period_id).await?;
|
||||||
|
if p.status == PeriodStatus::Closed {
|
||||||
|
return Err(AppError::Conflict("Bu dönem zaten kapalı".into()));
|
||||||
|
}
|
||||||
|
if version.is_some_and(|v| v != p.version) {
|
||||||
|
return Err(period::stale());
|
||||||
|
}
|
||||||
|
let realized = review::review(db, household_id, period_id)
|
||||||
|
.await?
|
||||||
|
.realized_savings;
|
||||||
|
let (to_savings, carry) = settle(realized, to_savings)?;
|
||||||
|
let next = next_period(db, household_id, &p).await?;
|
||||||
|
let ts = now();
|
||||||
|
|
||||||
|
let mut session = db.client().start_session().await?;
|
||||||
|
session.start_transaction().await?;
|
||||||
|
let result = async {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", period_id);
|
||||||
|
f.insert("version", p.version);
|
||||||
|
let r = db
|
||||||
|
.collection::<Document>(col::PERIODS)
|
||||||
|
.update_one(
|
||||||
|
f,
|
||||||
|
doc! {
|
||||||
|
"$set": {
|
||||||
|
"status": PeriodStatus::Closed.as_str(),
|
||||||
|
"closed_at": ts,
|
||||||
|
"closed_by": user_id,
|
||||||
|
"to_savings": to_savings,
|
||||||
|
"updated_at": ts,
|
||||||
|
},
|
||||||
|
"$inc": { "version": 1 },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.session(&mut session)
|
||||||
|
.await?;
|
||||||
|
if r.matched_count == 0 {
|
||||||
|
return Err(period::stale());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut old = scope(household_id);
|
||||||
|
old.insert("from_period_id", period_id);
|
||||||
|
db.collection::<Document>(col::CARRYOVERS)
|
||||||
|
.update_many(
|
||||||
|
old,
|
||||||
|
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.session(&mut session)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some((kind, amount)) = carry {
|
||||||
|
let mut d = new_meta(household_id);
|
||||||
|
d.insert("from_period_id", period_id);
|
||||||
|
d.insert(
|
||||||
|
"to_period_id",
|
||||||
|
next.as_ref().map_or(Bson::Null, |n| n.id.into()),
|
||||||
|
);
|
||||||
|
d.insert("kind", kind.as_str());
|
||||||
|
d.insert("amount", amount);
|
||||||
|
db.collection::<Document>(col::CARRYOVERS)
|
||||||
|
.insert_one(d)
|
||||||
|
.session(&mut session)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => session.commit_transaction().await?,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = session.abort_transaction().await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Closing {
|
||||||
|
period: period::get(db, household_id, period_id).await?,
|
||||||
|
realized_savings: realized,
|
||||||
|
to_savings,
|
||||||
|
carryover: carry.map(|(kind, amount)| Carry {
|
||||||
|
kind,
|
||||||
|
amount,
|
||||||
|
to_period_id: next.map(|n| n.id),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kapalı dönemi düzeltme için açar (yalnızca sahip — rota denetler).
|
||||||
|
pub async fn reopen(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
period_id: Uuid,
|
||||||
|
) -> AppResult<Period> {
|
||||||
|
let p = period::get(db, household_id, period_id).await?;
|
||||||
|
if p.status != PeriodStatus::Closed {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Yalnızca kapalı bir dönem yeniden açılabilir".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut later = scope(household_id);
|
||||||
|
later.insert("start_date", doc! { "$gte": &p.end_date });
|
||||||
|
later.insert("status", doc! { "$ne": PeriodStatus::Open.as_str() });
|
||||||
|
if db
|
||||||
|
.collection::<Document>(col::PERIODS)
|
||||||
|
.find_one(later)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Bu dönemden sonra kapanmış bir dönem var. Yalnızca en son kapanan dönem yeniden açılabilir."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ts = now();
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("_id", period_id);
|
||||||
|
f.insert("version", p.version);
|
||||||
|
let r = db
|
||||||
|
.collection::<Document>(col::PERIODS)
|
||||||
|
.update_one(
|
||||||
|
f,
|
||||||
|
doc! {
|
||||||
|
"$set": {
|
||||||
|
"status": PeriodStatus::Correcting.as_str(),
|
||||||
|
"reopened_at": ts,
|
||||||
|
"reopened_by": user_id,
|
||||||
|
"updated_at": ts,
|
||||||
|
},
|
||||||
|
"$inc": { "version": 1 },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if r.matched_count == 0 {
|
||||||
|
return Err(period::stale());
|
||||||
|
}
|
||||||
|
period::get(db, household_id, period_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bu dönemden hemen sonra gelen dönem (varsa)
|
||||||
|
async fn next_period(db: &Database, household_id: Uuid, p: &Period) -> AppResult<Option<Period>> {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("start_date", doc! { "$gte": &p.end_date });
|
||||||
|
Ok(db
|
||||||
|
.collection::<Period>(col::PERIODS)
|
||||||
|
.find_one(f)
|
||||||
|
.sort(doc! { "start_date": 1 })
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settle_rules() {
|
||||||
|
// Artı: varsayılan tamamı birikime
|
||||||
|
assert_eq!(settle(700, None).unwrap(), (700, None));
|
||||||
|
assert_eq!(
|
||||||
|
settle(700, Some(500)).unwrap(),
|
||||||
|
(500, Some((CarryKind::Surplus, 200)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
settle(700, Some(0)).unwrap(),
|
||||||
|
(0, Some((CarryKind::Surplus, 700)))
|
||||||
|
);
|
||||||
|
assert!(settle(700, Some(701)).is_err());
|
||||||
|
assert!(settle(700, Some(-1)).is_err());
|
||||||
|
// Sıfır: devir yok
|
||||||
|
assert_eq!(settle(0, None).unwrap(), (0, None));
|
||||||
|
// Açık: borç; birikim istenemez
|
||||||
|
assert_eq!(settle(-50, None).unwrap(), (0, Some((CarryKind::Debt, 50))));
|
||||||
|
assert_eq!(
|
||||||
|
settle(-50, Some(0)).unwrap(),
|
||||||
|
(0, Some((CarryKind::Debt, 50)))
|
||||||
|
);
|
||||||
|
assert!(settle(-50, Some(10)).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod account;
|
|||||||
pub mod budget;
|
pub mod budget;
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
|
pub mod closing;
|
||||||
pub mod entry;
|
pub mod entry;
|
||||||
pub mod invite;
|
pub mod invite;
|
||||||
pub mod money;
|
pub mod money;
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Perio
|
|||||||
/// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
|
/// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
|
||||||
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
|
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
|
||||||
let p = get(db, household_id, id).await?;
|
let p = get(db, household_id, id).await?;
|
||||||
if p.status == PeriodStatus::Closed {
|
if !p.status.is_writable() {
|
||||||
return Err(closed());
|
return Err(closed());
|
||||||
}
|
}
|
||||||
Ok(p)
|
Ok(p)
|
||||||
@@ -122,24 +122,51 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul
|
|||||||
d.insert("end_date", format_date(end));
|
d.insert("end_date", format_date(end));
|
||||||
d.insert("status", PeriodStatus::Open.as_str());
|
d.insert("status", PeriodStatus::Open.as_str());
|
||||||
d.insert("savings_goal", savings_goal);
|
d.insert("savings_goal", savings_goal);
|
||||||
d.insert("reflection_md", "");
|
d.insert("reflection_md", Bson::Null);
|
||||||
|
d.insert("to_savings", Bson::Null);
|
||||||
d.insert(
|
d.insert(
|
||||||
"previous_period_id",
|
"previous_period_id",
|
||||||
previous.map_or(Bson::Null, |p| p.id.into()),
|
previous.as_ref().map_or(Bson::Null, |p| p.id.into()),
|
||||||
);
|
);
|
||||||
d.insert("closed_at", Bson::Null);
|
d.insert("closed_at", Bson::Null);
|
||||||
d.insert("closed_by", Bson::Null);
|
d.insert("closed_by", Bson::Null);
|
||||||
d.insert("reopened_at", Bson::Null);
|
d.insert("reopened_at", Bson::Null);
|
||||||
|
|
||||||
match db.collection::<Document>(col::PERIODS).insert_one(d).await {
|
// Tek transaction: dönem + önceki dönemden bekleyen devrin bu döneme bağlanması
|
||||||
Ok(_) => {}
|
let mut session = db.client().start_session().await?;
|
||||||
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
|
session.start_transaction().await?;
|
||||||
Err(e) if is_duplicate_key(&e) => {
|
let result = async {
|
||||||
return Err(AppError::Conflict(
|
db.collection::<Document>(col::PERIODS)
|
||||||
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
|
.insert_one(d)
|
||||||
));
|
.session(&mut session)
|
||||||
|
.await?;
|
||||||
|
if let Some(prev) = &previous {
|
||||||
|
let mut f = scope(household_id);
|
||||||
|
f.insert("from_period_id", prev.id);
|
||||||
|
f.insert("to_period_id", Bson::Null);
|
||||||
|
db.collection::<Document>(col::CARRYOVERS)
|
||||||
|
.update_many(
|
||||||
|
f,
|
||||||
|
doc! { "$set": { "to_period_id": id.clone(), "updated_at": now() }, "$inc": { "version": 1 } },
|
||||||
|
)
|
||||||
|
.session(&mut session)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok::<_, mongodb::error::Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => session.commit_transaction().await?,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = session.abort_transaction().await;
|
||||||
|
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
|
||||||
|
if is_duplicate_key(&e) {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
coll(db)
|
coll(db)
|
||||||
@@ -155,7 +182,7 @@ pub async fn update(
|
|||||||
ch: PeriodChanges,
|
ch: PeriodChanges,
|
||||||
) -> AppResult<Period> {
|
) -> AppResult<Period> {
|
||||||
let current = get(db, household_id, id).await?;
|
let current = get(db, household_id, id).await?;
|
||||||
if current.status == PeriodStatus::Closed {
|
if !current.status.is_writable() {
|
||||||
return Err(closed());
|
return Err(closed());
|
||||||
}
|
}
|
||||||
if ch.version.is_some_and(|v| v != current.version) {
|
if ch.version.is_some_and(|v| v != current.version) {
|
||||||
@@ -297,7 +324,7 @@ async fn ensure_entries_inside(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stale() -> AppError {
|
pub fn stale() -> AppError {
|
||||||
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! Dönem kapatma, devir ve yeniden açma (düzeltme modu).
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{Session, TestApp};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str, goal: i64) -> String {
|
||||||
|
let (st, p) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
s,
|
||||||
|
json!({ "start_date": start, "end_date": end, "savings_goal": goal }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||||
|
p["id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn income(app: &TestApp, s: &Session, pid: &str, amount: i64) {
|
||||||
|
let (st, b) = app
|
||||||
|
.post(
|
||||||
|
&format!("/periods/{pid}/plan-items"),
|
||||||
|
s,
|
||||||
|
json!({ "kind": "income", "name": "Maaş", "amount": amount }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn needs_category(app: &TestApp, s: &Session) -> String {
|
||||||
|
let (_, cats) = app.get("/categories", s).await;
|
||||||
|
cats.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c["group"] == "needs")
|
||||||
|
.unwrap()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64) -> StatusCode {
|
||||||
|
let cat = needs_category(app, s).await;
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{pid}/entries"),
|
||||||
|
s,
|
||||||
|
json!({ "date": date, "amount": amount, "category_id": cat }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(app: &TestApp, s: &Session, pid: &str, body: Value) -> (StatusCode, Value) {
|
||||||
|
app.post(&format!("/periods/{pid}/close"), s, body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn summary(app: &TestApp, s: &Session, pid: &str) -> Value {
|
||||||
|
app.get(&format!("/periods/{pid}/summary"), s).await.1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn surplus_split_and_carryover_to_next_period() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01", 200_000).await;
|
||||||
|
income(&app, &s, &nov, 1_000_000).await;
|
||||||
|
assert_eq!(
|
||||||
|
spend(&app, &s, &nov, "2026-11-10", 300_000).await,
|
||||||
|
StatusCode::CREATED
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gerçekleşen birikim 700.000: 500.000 birikime, 200.000 artı olarak devreder
|
||||||
|
let (st, c) = close(&app, &s, &nov, json!({ "to_savings": 500_000 })).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{c}");
|
||||||
|
assert_eq!(c["realized_savings"], 700_000);
|
||||||
|
assert_eq!(c["to_savings"], 500_000);
|
||||||
|
assert_eq!(
|
||||||
|
c["carryover"],
|
||||||
|
json!({ "kind": "surplus", "amount": 200_000, "to_period_id": null })
|
||||||
|
);
|
||||||
|
assert_eq!(c["period"]["status"], "closed");
|
||||||
|
assert_eq!(c["period"]["to_savings"], 500_000);
|
||||||
|
assert!(c["period"]["closed_at"].is_string());
|
||||||
|
|
||||||
|
// Kapalı dönem salt okunur; açık dönem yok
|
||||||
|
assert_eq!(
|
||||||
|
spend(&app, &s, &nov, "2026-11-11", 1).await,
|
||||||
|
StatusCode::CONFLICT
|
||||||
|
);
|
||||||
|
let (_, cur) = app.get("/periods/current", &s).await;
|
||||||
|
assert_eq!(cur, Value::Null);
|
||||||
|
let (st, _) = close(&app, &s, &nov, json!({})).await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT, "iki kez kapatılamaz");
|
||||||
|
|
||||||
|
// Sonraki dönem açılınca devir ona bağlanır → harcanabilir tutara eklenir
|
||||||
|
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01", 0).await;
|
||||||
|
income(&app, &s, &dec, 1_000_000).await;
|
||||||
|
let sum = summary(&app, &s, &dec).await;
|
||||||
|
assert_eq!(
|
||||||
|
(sum["carryover_surplus"].as_i64(), sum["spendable"].as_i64()),
|
||||||
|
(Some(200_000), Some(1_200_000))
|
||||||
|
);
|
||||||
|
let (_, r) = app.get(&format!("/periods/{dec}/review"), &s).await;
|
||||||
|
assert_eq!(r["available"], 1_200_000);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deficit_carries_over_as_debt_and_validation() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01", 0).await;
|
||||||
|
income(&app, &s, &nov, 100_000).await;
|
||||||
|
spend(&app, &s, &nov, "2026-11-10", 150_000).await;
|
||||||
|
|
||||||
|
let (st, b) = close(&app, &s, &nov, json!({ "to_savings": 10 })).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(
|
||||||
|
b["error"]["message"],
|
||||||
|
"Bu dönemde birikim yok; açık, borç olarak sonraki döneme devreder"
|
||||||
|
);
|
||||||
|
let (st, _) = close(&app, &s, &nov, json!({ "version": 99 })).await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT, "eski sürüm");
|
||||||
|
|
||||||
|
let (st, c) = close(&app, &s, &nov, json!({})).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{c}");
|
||||||
|
assert_eq!(c["carryover"]["kind"], "debt");
|
||||||
|
assert_eq!(c["carryover"]["amount"], 50_000);
|
||||||
|
|
||||||
|
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01", 0).await;
|
||||||
|
let sum = summary(&app, &s, &dec).await;
|
||||||
|
assert_eq!(sum["carryover_debt"], 50_000);
|
||||||
|
assert_eq!(sum["spendable"], -50_000);
|
||||||
|
|
||||||
|
// Artıda: birikim tutarı sınırların dışında → 400
|
||||||
|
income(&app, &s, &dec, 300_000).await;
|
||||||
|
for bad in [-1, 250_001] {
|
||||||
|
let (st, b) = close(&app, &s, &dec, json!({ "to_savings": bad })).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST, "{bad}: {b}");
|
||||||
|
}
|
||||||
|
// Varsayılan: tamamı birikime, devir yok
|
||||||
|
let (_, c) = close(&app, &s, &dec, json!({})).await;
|
||||||
|
assert_eq!(
|
||||||
|
(c["to_savings"].as_i64(), &c["carryover"]),
|
||||||
|
(Some(250_000), &Value::Null)
|
||||||
|
);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reopen_for_correction_while_next_period_is_open() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let owner = app.register("Sahip", None).await;
|
||||||
|
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||||
|
let member = app
|
||||||
|
.register("Uye", Some(inv["code"].as_str().unwrap()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let nov = open_period(&app, &owner, "2026-11-01", "2026-12-01", 0).await;
|
||||||
|
income(&app, &owner, &nov, 500_000).await;
|
||||||
|
spend(&app, &owner, &nov, "2026-11-05", 100_000).await;
|
||||||
|
close(&app, &owner, &nov, json!({ "to_savings": 0 })).await; // 400.000 artı
|
||||||
|
let dec = open_period(&app, &owner, "2026-12-01", "2027-01-01", 0).await;
|
||||||
|
assert_eq!(
|
||||||
|
summary(&app, &owner, &dec).await["carryover_surplus"],
|
||||||
|
400_000
|
||||||
|
);
|
||||||
|
|
||||||
|
// Açık dönem yeniden açılamaz; üye yeniden açamaz
|
||||||
|
let (st, _) = app
|
||||||
|
.post(&format!("/periods/{dec}/reopen"), &owner, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT);
|
||||||
|
let (st, _) = app
|
||||||
|
.post(&format!("/periods/{nov}/reopen"), &member, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// Sahip: Kasım düzeltmeye açılır, Aralık açık kalır
|
||||||
|
let (st, p) = app
|
||||||
|
.post(&format!("/periods/{nov}/reopen"), &owner, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{p}");
|
||||||
|
assert_eq!(p["status"], "correcting");
|
||||||
|
let (_, cur) = app.get("/periods/current", &owner).await;
|
||||||
|
assert_eq!(cur["id"], dec.as_str());
|
||||||
|
// Düzeltme: Kasım'a unutulan harcama eklenir (üye de ekleyebilir)
|
||||||
|
assert_eq!(
|
||||||
|
spend(&app, &member, &nov, "2026-11-20", 150_000).await,
|
||||||
|
StatusCode::CREATED
|
||||||
|
);
|
||||||
|
// Yeniden kapatılana kadar Aralık'ın devri değişmez
|
||||||
|
assert_eq!(
|
||||||
|
summary(&app, &owner, &dec).await["carryover_surplus"],
|
||||||
|
400_000
|
||||||
|
);
|
||||||
|
|
||||||
|
// Yeniden kapat → devir yeniden hesaplanır ve doğrudan Aralık'a bağlanır
|
||||||
|
let (st, c) = close(&app, &owner, &nov, json!({ "to_savings": 0 })).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{c}");
|
||||||
|
assert_eq!(
|
||||||
|
c["carryover"],
|
||||||
|
json!({ "kind": "surplus", "amount": 250_000, "to_period_id": dec })
|
||||||
|
);
|
||||||
|
let sum = summary(&app, &owner, &dec).await;
|
||||||
|
assert_eq!(
|
||||||
|
sum["carryover_surplus"], 250_000,
|
||||||
|
"eski devir silindi, yenisi geçerli"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aralık kapanınca Kasım artık yeniden açılamaz (zincir bozulurdu)
|
||||||
|
close(&app, &owner, &dec, json!({})).await;
|
||||||
|
let (st, b) = app
|
||||||
|
.post(&format!("/periods/{nov}/reopen"), &owner, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CONFLICT, "{b}");
|
||||||
|
let (st, _) = app
|
||||||
|
.post(&format!("/periods/{dec}/reopen"), &owner, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn closing_is_isolated_between_households() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let a = app.register("Ahane", None).await;
|
||||||
|
let b = app.register("Bhane", None).await;
|
||||||
|
let pid = open_period(&app, &a, "2026-11-01", "2026-12-01", 0).await;
|
||||||
|
|
||||||
|
let (st, _) = close(&app, &b, &pid, json!({})).await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
close(&app, &a, &pid, json!({})).await;
|
||||||
|
let (st, _) = app
|
||||||
|
.post(&format!("/periods/{pid}/reopen"), &b, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
+7
-5
@@ -81,16 +81,17 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
|||||||
```js
|
```js
|
||||||
{ _id, household_id, name /* "Aralık 2026" */,
|
{ _id, household_id, name /* "Aralık 2026" */,
|
||||||
start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ
|
start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ
|
||||||
status: "open" | "closed",
|
status: "open" | "closed" | "correcting",
|
||||||
savings_goal: 500000, // kuruş — birikim hedefi
|
savings_goal: 500000, // kuruş — birikim hedefi
|
||||||
reflection_md: "...", // "Nasıl daha iyi yapabilirim?"
|
reflection_md: "...", // "Nasıl daha iyi yapabilirim?"
|
||||||
closed_at, closed_by, reopened_at,
|
to_savings: 450000, // kapanışta birikime aktarılan (Faz 8'de varlık hareketi)
|
||||||
|
closed_at, closed_by, reopened_at, reopened_by,
|
||||||
previous_period_id,
|
previous_period_id,
|
||||||
created_at, updated_at, deleted_at, version }
|
created_at, updated_at, deleted_at, version }
|
||||||
```
|
```
|
||||||
- **Bitiş tarihi hariç:** `01.12.2026 – 01.01.2027` → 1–31 Aralık. Dönemler arka arkaya boşluksuz ve çakışmasız eklenir.
|
- **Bitiş tarihi hariç:** `01.12.2026 – 01.01.2027` → 1–31 Aralık. Dönemler arka arkaya boşluksuz ve çakışmasız eklenir.
|
||||||
- Hanede en fazla **bir** `open` dönem (servis kontrolü + kısmi benzersiz indeks).
|
- Hanede en fazla **bir** `open` dönem (servis kontrolü + kısmi benzersiz indeks).
|
||||||
- Yeniden açılan dönemin durumu tekrar `open` olur. O sırada başka açık dönem varsa önce o kapatılmalı mı, yoksa geçici olarak iki açık dönem mi olsun? → **Açık konu, Faz 6'da netleşecek.**
|
- **Yeniden açma (karar 2026-11-12, "düzeltme modu"):** sahip kapalı dönemi açınca durum `correcting` olur; yazılabilir ama "açık dönem" sayılmaz → yeni dönem açık kalabilir (tek `open` kuralı korunur). Mevcut devir "yeniden kapat"a kadar yerinde kalır; yeniden kapatınca eskisi soft delete, yenisi hesaplanır. Ardından kapanmış dönem varsa açılamaz (zincirleme devirler bozulmasın).
|
||||||
- **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (Pazartesi başlangıçlı; ilk/son kısmi haftalar ayrı).
|
- **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (Pazartesi başlangıçlı; ilk/son kısmi haftalar ayrı).
|
||||||
|
|
||||||
### 3.5 `categories` — Alt kategoriler
|
### 3.5 `categories` — Alt kategoriler
|
||||||
@@ -143,7 +144,8 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
|||||||
kind: "debt" | "surplus", amount: 120000,
|
kind: "debt" | "surplus", amount: 120000,
|
||||||
created_at, ... }
|
created_at, ... }
|
||||||
```
|
```
|
||||||
- Dönem kapanışında oluşur. Dönem yeniden açılırsa soft delete edilir ve kapanışta yeniden hesaplanır.
|
- Dönem kapanışında oluşur. Sonraki dönem henüz yoksa `to_period_id: null`; o dönem açılınca (aynı transaction'da) bağlanır.
|
||||||
|
- Yeniden kapatmada eskisi soft delete edilir, yenisi yazılır (düzeltme süresince eskisi geçerli kalır).
|
||||||
|
|
||||||
### 3.11 `notes`
|
### 3.11 `notes`
|
||||||
```js
|
```js
|
||||||
@@ -223,7 +225,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
|||||||
## 6. Dönem Kapanışı (tek transaction)
|
## 6. Dönem Kapanışı (tek transaction)
|
||||||
1. `periods.status = "closed"`, `closed_at`, `closed_by`
|
1. `periods.status = "closed"`, `closed_at`, `closed_by`
|
||||||
2. `carryovers` kaydı (borç veya artı) → sonraki dönem
|
2. `carryovers` kaydı (borç veya artı) → sonraki dönem
|
||||||
3. (Onaylıysa) gerçekleşen birikim → `asset_transactions` (`TRY`, `buy`, `source_period_id`)
|
3. Gerçekleşen birikimin `to_savings` kadarı birikime: şimdilik `periods.to_savings`; Faz 8'de → `asset_transactions` (`TRY`, `buy`, `source_period_id`)
|
||||||
4. (İstenirse) yeni dönem: `plan_items` ve aylık `budgets` kopyalanarak açılır
|
4. (İstenirse) yeni dönem: `plan_items` ve aylık `budgets` kopyalanarak açılır
|
||||||
|
|
||||||
Transaction'ın bir adımı hata verirse önceki adımlar da geri alınır, hiçbir şey değişmez.
|
Transaction'ın bir adımı hata verirse önceki adımlar da geri alınır, hiçbir şey değişmez.
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ Sıradaki Adım
|
||||||
**Faz 6 – Adım 2:** Backend dönem kapatma (`POST /periods/{id}/close`, tek transaction): `status=closed`, `closed_at/by`; gerçekleşen birikim < 0 → `carryovers{kind: debt}`, > 0 → kullanıcının `to_savings` kadarı birikime (Faz 8'e kadar dönemde saklanır), kalanı `surplus`; devrin `to_period_id`'si sonraki dönem açılınca bağlanır. Yeniden açma (sahip): devirler soft delete. **Kullanıcıya sorulacak:** başka açık dönem varken eski dönem nasıl yeniden açılır?
|
**Faz 6 – Adım 3:** Frontend Değerlendirme sayfası (`/degerlendirme`, açık dönem; ileride `?donem=`): 4 soru kartı (`PeriodReview`), grup planlanan/gerçekleşen çubukları, en çok harcanan kategoriler (`useCategories(true)` ile ad), haftalık dağılım, "Nasıl daha iyi yapabilirim?" (Markdown editör → `PATCH reflection_md`), notlar `NotesSection context="review"` (yalnızca başlık listesi, tıklayınca açılır), "🔒 Dönemi Kapat" düğmesi (sihirbaz Adım 4).
|
||||||
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
||||||
|
|
||||||
## 📌 Alınan Kararlar
|
## 📌 Alınan Kararlar
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
- **Canlı yedekleme kurulumu bekliyor** (betikler hazır, `docs/DEPLOY.md` §6) → gerçek veri girişinden önce.
|
- **Canlı yedekleme kurulumu bekliyor** (betikler hazır, `docs/DEPLOY.md` §6) → gerçek veri girişinden önce.
|
||||||
- **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+).
|
||||||
- Kapalı dönem yeniden açılırken başka açık dönem varsa davranış (Faz 6).
|
- ~~Kapalı dönem yeniden açma~~ → çözüldü: düzeltme modu (`correcting`). Arayüzde düzeltmedeki dönem için Defter/Plan dönem seçimi Faz 6 Adım 5'te.
|
||||||
- Altın kuru için veri kaynağı seçimi (Faz 8).
|
- Altın kuru için veri kaynağı seçimi (Faz 8).
|
||||||
- Aylık değerlendirme çıktı formatı kullanıcıdan gelecek (Faz 9).
|
- Aylık değerlendirme çıktı formatı kullanıcıdan gelecek (Faz 9).
|
||||||
- Canlı MongoDB kurulum şekli (konteyner mi, doğrudan mı) Faz 3'te birlikte kontrol edilecek.
|
- Canlı MongoDB kurulum şekli (konteyner mi, doğrudan mı) Faz 3'te birlikte kontrol edilecek.
|
||||||
@@ -108,3 +108,4 @@
|
|||||||
|
|
||||||
### 2026-11-12 — Faz 6
|
### 2026-11-12 — Faz 6
|
||||||
- **Adım 1:** Değerlendirme backend: `services/review.rs` (`budget::summary` + `budget::view` + kategori aggregation), `routes/review.rs` → `GET /periods/{id}/review` (`PeriodReview`: `available` = gelir − sabit − borç + artı, `savings_goal`, `spent_total`, `realized_savings` = available − harcanan, `savings_gap`; `groups` planned/spent, `categories` çoktan aza (+count), `weeks` 4 grubun toplamı). Kapalı dönemde okunur. `periods.reflection_md` (PATCH, ≤ 20.000 karakter, `""` → null, yalnızca açık dönem). `tests/review.rs` (3). Mutasyon: 4. soruya hedef düşmek / silinenleri saymak → yakalandı. Not: `TestApp::register` adı e-postaya çevirir → Türkçe karakterli ad kullanma.
|
- **Adım 1:** Değerlendirme backend: `services/review.rs` (`budget::summary` + `budget::view` + kategori aggregation), `routes/review.rs` → `GET /periods/{id}/review` (`PeriodReview`: `available` = gelir − sabit − borç + artı, `savings_goal`, `spent_total`, `realized_savings` = available − harcanan, `savings_gap`; `groups` planned/spent, `categories` çoktan aza (+count), `weeks` 4 grubun toplamı). Kapalı dönemde okunur. `periods.reflection_md` (PATCH, ≤ 20.000 karakter, `""` → null, yalnızca açık dönem). `tests/review.rs` (3). Mutasyon: 4. soruya hedef düşmek / silinenleri saymak → yakalandı. Not: `TestApp::register` adı e-postaya çevirir → Türkçe karakterli ad kullanma.
|
||||||
|
- **Adım 2:** Kapatma backend: `services/closing.rs` — `settle(realized, to_savings)` (< 0 → borç, birikim istenemez; ≥ 0 → varsayılan tamamı birikime, kalan artı), `close` (tek transaction: dönem `closed` + `closed_at/by` + `to_savings`, bu dönemden eski devirler soft delete, yeni devir; `to_period_id` = sonraki dönem ya da null), `reopen` (sahip; `closed` → **`correcting`**; ardından kapalı/düzeltmede dönem varsa 409). `PeriodStatus::Correcting` + `is_writable()` → `get_open`/`update` buna bakar; `current()` yalnız `open`. `period::create` artık transaction: önceki dönemin bağsız devrini bağlar. `POST /periods/{id}/close` (`PeriodCloseRequest`/`PeriodCloseResult`/`Carryover`), `POST /periods/{id}/reopen`. Period DTO: `to_savings`, `closed_at`. `tests/closing.rs` (4) + `settle` birim testi. Mutasyon 3/3 yakalandı; hata zorlanınca transaction geri aldı (dönem açık, sürüm aynı). Backend 113 test. Karar: yeniden açma = düzeltme modu (kullanıcı "a").
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
## Faz 6 Adımları (taslak)
|
## Faz 6 Adımları (taslak)
|
||||||
- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md`
|
- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md`
|
||||||
- ⏳ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip)
|
- ✅ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip)
|
||||||
- ⏳ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar)
|
- ⏳ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar)
|
||||||
- ⏳ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
|
- ⏳ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
|
||||||
- ⏳ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip)
|
- ⏳ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const period: Period = {
|
|||||||
status: 'open',
|
status: 'open',
|
||||||
savings_goal: 500_000,
|
savings_goal: 500_000,
|
||||||
reflection_md: null,
|
reflection_md: null,
|
||||||
|
to_savings: null,
|
||||||
|
closed_at: null,
|
||||||
version: 3,
|
version: 3,
|
||||||
weeks: [
|
weeks: [
|
||||||
{ index: 1, start_date: '2026-12-01', last_date: '2026-12-06', days: 6 },
|
{ index: 1, start_date: '2026-12-01', last_date: '2026-12-06', days: 6 },
|
||||||
|
|||||||
@@ -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 Carryover = {
|
||||||
|
/**
|
||||||
|
* `debt` (borç) · `surplus` (artı)
|
||||||
|
*/
|
||||||
|
kind: "debt" | "surplus", amount: number,
|
||||||
|
/**
|
||||||
|
* Sonraki dönem henüz açılmadıysa `null` (açılınca bağlanır)
|
||||||
|
*/
|
||||||
|
to_period_id: string | null, };
|
||||||
@@ -22,4 +22,12 @@ savings_goal: number,
|
|||||||
/**
|
/**
|
||||||
* "Nasıl daha iyi yapabilirim?" (Markdown)
|
* "Nasıl daha iyi yapabilirim?" (Markdown)
|
||||||
*/
|
*/
|
||||||
reflection_md: string | null, version: number, weeks: Array<PeriodWeek>, };
|
reflection_md: string | null,
|
||||||
|
/**
|
||||||
|
* Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null`
|
||||||
|
*/
|
||||||
|
to_savings: number | null,
|
||||||
|
/**
|
||||||
|
* Kapanış zamanı (ISO 8601)
|
||||||
|
*/
|
||||||
|
closed_at: string | null, version: number, weeks: Array<PeriodWeek>, };
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type PeriodCloseRequest = {
|
||||||
|
/**
|
||||||
|
* Birikime aktarılacak (kuruş); verilmezse gerçekleşen birikimin tamamı. Kalanı artı olarak devreder.
|
||||||
|
*/
|
||||||
|
to_savings?: number, version?: number, };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Carryover } from "./Carryover";
|
||||||
|
import type { Period } from "./Period";
|
||||||
|
|
||||||
|
export type PeriodCloseResult = { period: Period,
|
||||||
|
/**
|
||||||
|
* Değerlendirmenin 4. sorusu (negatifse açık)
|
||||||
|
*/
|
||||||
|
realized_savings: number, to_savings: number,
|
||||||
|
/**
|
||||||
|
* Sonraki döneme devreden; yoksa `null`
|
||||||
|
*/
|
||||||
|
carryover: Carryover | null, };
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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";
|
export type PeriodStatus = "open" | "closed" | "correcting";
|
||||||
|
|||||||
Reference in New Issue
Block a user