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;
}