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:
2026-11-12 12:20:09 +03:00
parent 8ec1fd6f25
commit 62e92f552a
17 changed files with 724 additions and 26 deletions
+11
View File
@@ -313,3 +313,14 @@ Content-Type: application/json
{
"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
+17 -2
View File
@@ -1,6 +1,6 @@
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
use mongodb::bson::Uuid;
use mongodb::bson::{DateTime, Uuid};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -10,8 +10,12 @@ use ts_rs::TS;
pub enum PeriodStatus {
/// Girişler yapılabilir. Hanede en fazla bir tane.
Open,
/// Salt okunur (Faz 6: kapanış sihirbazı, sahip yeniden açabilir).
/// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
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 {
@@ -19,8 +23,14 @@ impl PeriodStatus {
match self {
PeriodStatus::Open => "open",
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)]
@@ -38,5 +48,10 @@ pub struct Period {
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
#[serde(default)]
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,
}
+2
View File
@@ -68,6 +68,8 @@ pub fn router(state: AppState) -> Router {
.route("/periods", get(period::list).post(period::create))
.route("/periods/current", get(period::current))
.route("/periods/{id}", get(period::get).patch(period::update))
.route("/periods/{id}/close", post(period::close))
.route("/periods/{id}/reopen", post(period::reopen))
.route(
"/periods/{id}/plan-items",
get(plan::list).post(plan::create),
+84 -1
View File
@@ -6,7 +6,9 @@
//! | 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 |
//! | 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::{
Json,
@@ -25,6 +27,7 @@ use crate::{
models::period::{Period, PeriodStatus},
services::{
calendar::{days_between, format_date, parse_date, weeks},
closing,
period::{self, NewPeriod, PeriodChanges},
},
state::AppState,
@@ -77,6 +80,11 @@ pub struct PeriodDto {
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>,
}
@@ -107,6 +115,8 @@ impl From<Period> for PeriodDto {
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()
@@ -190,3 +200,76 @@ pub async fn update(
.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>,
}
#[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()))
}
+270
View File
@@ -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());
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod account;
pub mod budget;
pub mod calendar;
pub mod catalog;
pub mod closing;
pub mod entry;
pub mod invite;
pub mod money;
+40 -13
View File
@@ -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.
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
let p = get(db, household_id, id).await?;
if p.status == PeriodStatus::Closed {
if !p.status.is_writable() {
return Err(closed());
}
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("status", PeriodStatus::Open.as_str());
d.insert("savings_goal", savings_goal);
d.insert("reflection_md", "");
d.insert("reflection_md", Bson::Null);
d.insert("to_savings", Bson::Null);
d.insert(
"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_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(),
));
// Tek transaction: dönem + önceki dönemden bekleyen devrin bu döneme bağlanması
let mut session = db.client().start_session().await?;
session.start_transaction().await?;
let result = async {
db.collection::<Document>(col::PERIODS)
.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)
@@ -155,7 +182,7 @@ pub async fn update(
ch: PeriodChanges,
) -> AppResult<Period> {
let current = get(db, household_id, id).await?;
if current.status == PeriodStatus::Closed {
if !current.status.is_writable() {
return Err(closed());
}
if ch.version.is_some_and(|v| v != current.version) {
@@ -297,7 +324,7 @@ async fn ensure_entries_inside(
Ok(())
}
fn stale() -> AppError {
pub fn stale() -> AppError {
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
+245
View File
@@ -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
View File
@@ -81,16 +81,17 @@ Renkler ve ikonlar frontend tasarım token'larında.
```js
{ _id, household_id, name /* "Aralık 2026" */,
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
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,
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.
- 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ı).
### 3.5 `categories` — Alt kategoriler
@@ -143,7 +144,8 @@ Renkler ve ikonlar frontend tasarım token'larında.
kind: "debt" | "surplus", amount: 120000,
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`
```js
@@ -223,7 +225,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
## 6. Dönem Kapanışı (tek transaction)
1. `periods.status = "closed"`, `closed_at`, `closed_by`
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
Transaction'ın bir adımı hata verirse önceki adımlar da geri alınır, hiçbir şey değişmez.
+3 -2
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ 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 ✅.
## 📌 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ı 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+).
- 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).
- 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.
@@ -108,3 +108,4 @@
### 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 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
View File
@@ -62,7 +62,7 @@
## Faz 6 Adımları (taslak)
- ✅ 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)
- ⏳ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
- ⏳ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip)
+2
View File
@@ -14,6 +14,8 @@ const period: Period = {
status: 'open',
savings_goal: 500_000,
reflection_md: null,
to_savings: null,
closed_at: null,
version: 3,
weeks: [
{ index: 1, start_date: '2026-12-01', last_date: '2026-12-06', days: 6 },
+11
View File
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type 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, };
+9 -1
View File
@@ -22,4 +22,12 @@ savings_goal: number,
/**
* "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>, };
+7
View File
@@ -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, };
+13
View File
@@ -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 -1
View File
@@ -1,3 +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";
export type PeriodStatus = "open" | "closed" | "correcting";