62e92f552a
- 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>
246 lines
8.5 KiB
Rust
246 lines
8.5 KiB
Rust
//! 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;
|
||
}
|