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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user