feat(birikim): dönem kapanışındaki birikimi varlıklara ekleme

- Kapatma sonucunda onayla "Birikime ekle" (TL alımı, dönemin son günü)
- Birikimler'de "Bekleyen birikimler" listesi
- Yeniden kapatmada bağlı kayıt yeni tutara güncellenir (0 → silinir), TL çıkışları aşılırsa reddedilir
- Panel "Toplam birikim" artık portföyün güncel değeri

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 01:27:07 +03:00
parent 6b7b69c987
commit 48554e0ff6
20 changed files with 634 additions and 9 deletions
+6
View File
@@ -371,3 +371,9 @@ Content-Type: application/json
### Elle kuru sil → otomatik kura dön
DELETE {{baseUrl}}/rates/BURAYA-KUR-ID
### Kapanıp birikime henüz eklenmemiş dönemler
GET {{baseUrl}}/savings/pending
### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez)
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/to-savings
+47
View File
@@ -9,6 +9,8 @@
//! | POST | `/api/assets/{id}/transactions` | alım / satım ekle |
//! | PATCH | `/api/asset-transactions/{id}` | işlemi düzenle |
//! | DELETE | `/api/asset-transactions/{id}` | işlemi sil (soft) |
//! | GET | `/api/savings/pending` | kapanıp birikime henüz eklenmemiş dönemler |
//! | POST | `/api/periods/{id}/to-savings` | dönemin birikimini TL varlığına alım olarak ekle |
use axum::{
Json,
@@ -292,3 +294,48 @@ pub async fn delete_tx(
asset::delete_tx(&s.db, auth.household_id, path_id(&id)?).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PendingSavings")]
pub struct PendingDto {
period_id: String,
name: String,
/// Dönemin son günü (eklenecek alımın tarihi)
last_date: String,
/// Kuruş
#[ts(type = "number")]
to_savings: i64,
}
pub async fn pending(
State(s): State<AppState>,
auth: AuthUser,
) -> AppResult<Json<Vec<PendingDto>>> {
let list = asset::pending_period_savings(&s.db, auth.household_id).await?;
Ok(Json(
list.into_iter()
.map(|p| {
let end = crate::services::calendar::parse_date(&p.end_date)
.expect("geçerli bitiş tarihi");
PendingDto {
period_id: p.id.to_string(),
name: p.name,
last_date: crate::services::calendar::format_date(
end - time::Duration::days(1),
),
to_savings: p.to_savings.unwrap_or(0),
}
})
.collect(),
))
}
pub async fn add_period_savings(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<(StatusCode, Json<TxDto>)> {
let t =
asset::add_period_savings(&s.db, auth.household_id, auth.user_id, path_id(&id)?).await?;
Ok((StatusCode::CREATED, Json(t.into())))
}
+2
View File
@@ -102,6 +102,8 @@ pub fn router(state: AppState) -> Router {
.route("/entries/{id}/restore", post(entry::restore))
.route("/assets", get(asset::list).post(asset::create))
.route("/portfolio", get(rates::portfolio))
.route("/savings/pending", get(asset::pending))
.route("/periods/{id}/to-savings", post(asset::add_period_savings))
.route("/rates", post(rates::create))
.route("/rates/{id}", delete(rates::delete))
.route("/assets/{id}", patch(asset::update))
+5
View File
@@ -9,6 +9,7 @@
//! | 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`) |
//! | POST | `/api/periods/{id}/to-savings` | kapanıştaki birikimi TL varlığına ekle (bkz. `routes/asset.rs`) |
use axum::{
Json,
@@ -223,6 +224,9 @@ pub struct CloseResultDto {
to_savings: i64,
/// Sonraki döneme devreden; yoksa `null`
carryover: Option<CarryoverDto>,
/// Birikimlerde bu döneme bağlı kayıt var (yeniden kapatmada yeni tutara güncellendi).
/// `false` ve `to_savings > 0` → arayüz "Birikime ekle" önerir.
savings_linked: bool,
}
#[derive(Serialize, TS)]
@@ -256,6 +260,7 @@ pub async fn close(
period: c.period.into(),
realized_savings: c.realized_savings,
to_savings: c.to_savings,
savings_linked: c.savings_linked,
carryover: c.carryover.map(|x| CarryoverDto {
kind: x.kind.as_str(),
amount: x.amount,
+190
View File
@@ -429,3 +429,193 @@ fn clean_note(n: Option<&str>) -> AppResult<Option<String>> {
fn stale() -> AppError {
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
// ---------- Dönem kapanışından birikim ----------
/// Kapanışta birikime aktarılan tutarın TL varlığına eklenmiş kaydı (dönem başına tek)
pub async fn period_savings_tx(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Option<AssetTx>> {
let mut f = scope(household_id);
f.insert("source_period_id", period_id);
Ok(txs(db).find_one(f).await?)
}
async fn cash_asset(db: &Database, household_id: Uuid) -> AppResult<Asset> {
let mut f = scope(household_id);
f.insert("kind", "cash");
assets(db)
.find_one(f)
.await?
.ok_or_else(|| AppError::Internal("hanenin TL varlığı yok".into()))
}
/// Kapalı dönemin `to_savings` tutarını TL varlığına **alım** olarak ekler (kullanıcı onayıyla).
/// Tarih = dönemin son günü. Aynı dönem ikinci kez eklenemez.
pub async fn add_period_savings(
db: &Database,
household_id: Uuid,
user_id: Uuid,
period_id: Uuid,
) -> AppResult<AssetTx> {
let p = crate::services::period::get(db, household_id, period_id).await?;
if p.status != crate::models::period::PeriodStatus::Closed {
return Err(AppError::BadRequest(
"Dönem kapanmadan birikime eklenemez".into(),
));
}
let amount = p.to_savings.unwrap_or(0);
if amount <= 0 {
return Err(AppError::BadRequest(
"Bu dönemde birikime aktarılan tutar yok".into(),
));
}
if period_savings_tx(db, household_id, period_id)
.await?
.is_some()
{
return Err(AppError::Conflict(
"Bu dönemin birikimi zaten eklendi".into(),
));
}
let cash = cash_asset(db, household_id).await?;
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("asset_id", cash.id);
d.insert("type", TxType::Buy.as_str());
d.insert("date", last_day(&p.end_date)?);
d.insert("quantity_e6", amount * 10_000);
d.insert("unit_price_e6", ONE_E6);
d.insert("total", amount);
d.insert("note", format!("{} kapanışı", p.name));
d.insert("source_period_id", period_id);
d.insert("created_by", user_id);
db.collection::<Document>(col::ASSET_TRANSACTIONS)
.insert_one(d)
.await?;
txs(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen işlem okunamadı".into()))
}
/// Kapalı, birikime aktarılan tutarı olan ama henüz eklenmemiş dönemler (en yeni başta)
pub async fn pending_period_savings(
db: &Database,
household_id: Uuid,
) -> AppResult<Vec<crate::models::period::Period>> {
let linked: Vec<Uuid> = {
let mut f = scope(household_id);
f.insert("source_period_id", doc! { "$ne": null });
let mut c = txs(db).find(f).await?;
let mut v = Vec::new();
while c.advance().await? {
if let Some(p) = c.deserialize_current()?.source_period_id {
v.push(p);
}
}
v
};
Ok(crate::services::period::list(db, household_id)
.await?
.into_iter()
.filter(|p| {
p.status == crate::models::period::PeriodStatus::Closed
&& p.to_savings.unwrap_or(0) > 0
&& !linked.contains(&p.id)
})
.collect())
}
/// Yeniden kapatmada bağlı kayda yapılacak iş (transaction'dan önce hazırlanıp denetlenir)
pub enum SavingsSync {
Update { id: Uuid, amount: i64, date: String },
Delete { id: Uuid },
}
/// Bağlı kayıt varsa yeni tutar/tarihe göre işi hazırlar. Tutar 0 → silinir.
/// TL'de sonraki satışlar eldeki miktarı aşacaksa reddedilir (kapatma da yapılmaz).
pub async fn plan_savings_sync(
db: &Database,
household_id: Uuid,
period_id: Uuid,
amount: i64,
end_date: &str,
) -> AppResult<Option<SavingsSync>> {
let Some(linked) = period_savings_tx(db, household_id, period_id).await? else {
return Ok(None);
};
let date = last_day(end_date)?;
let mut list: Vec<AssetTx> = all_txs(db, household_id, Some(linked.asset_id))
.await?
.into_iter()
.filter(|x| x.id != linked.id)
.collect();
if amount > 0 {
let mut changed = linked.clone();
changed.date = date.clone();
changed.quantity_e6 = amount * 10_000;
changed.total = amount;
let pos = list.partition_point(|x| {
(x.date.as_str(), x.id.bytes()) <= (date.as_str(), linked.id.bytes())
});
list.insert(pos, changed);
}
if holding::holding(&moves(&list)).is_err() {
return Err(AppError::Conflict(
"Birikimden yapılan TL çıkışları yeni tutarı aşıyor; önce Birikimler'deki kayıtları düzeltin"
.into(),
));
}
Ok(Some(if amount > 0 {
SavingsSync::Update {
id: linked.id,
amount,
date,
}
} else {
SavingsSync::Delete { id: linked.id }
}))
}
/// Hazırlanan işi kapatma transaction'ı içinde uygular
pub async fn apply_savings_sync(
db: &Database,
session: &mut mongodb::ClientSession,
household_id: Uuid,
sync: &SavingsSync,
) -> AppResult<()> {
let ts = now();
let (id, set) = match sync {
SavingsSync::Update { id, amount, date } => (
*id,
doc! {
"date": date,
"quantity_e6": amount * 10_000,
"unit_price_e6": ONE_E6,
"total": amount,
"updated_at": ts,
},
),
SavingsSync::Delete { id } => (*id, doc! { "deleted_at": ts, "updated_at": ts }),
};
let mut f = scope(household_id);
f.insert("_id", id);
db.collection::<Document>(col::ASSET_TRANSACTIONS)
.update_one(f, doc! { "$set": set, "$inc": { "version": 1 } })
.session(session)
.await?;
Ok(())
}
/// Hariç bitiş → son gün (dahil)
fn last_day(end_date: &str) -> AppResult<String> {
let end =
parse_date(end_date).ok_or_else(|| AppError::Internal("dönem bitişi geçersiz".into()))?;
Ok(crate::services::calendar::format_date(
end - time::Duration::days(1),
))
}
+11 -1
View File
@@ -25,7 +25,7 @@ use crate::{
common::{new_meta, now, scope},
period::{Period, PeriodStatus},
},
services::{period, review},
services::{asset, period, review},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -54,6 +54,8 @@ pub struct Closing {
pub realized_savings: i64,
pub to_savings: i64,
pub carryover: Option<Carry>,
/// Birikime eklenmiş kayıt var (yeniden kapatmada güncellendi)
pub savings_linked: bool,
}
/// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime.
@@ -99,6 +101,9 @@ pub async fn close(
.realized_savings;
let (to_savings, carry) = settle(realized, to_savings)?;
let next = next_period(db, household_id, &p).await?;
// Yeniden kapatma: birikime eklenmiş kayıt varsa yeni tutara uyarlanır (önce denetlenir)
let savings_sync =
asset::plan_savings_sync(db, household_id, period_id, to_savings, &p.end_date).await?;
let ts = now();
let mut session = db.client().start_session().await?;
@@ -138,6 +143,10 @@ pub async fn close(
.session(&mut session)
.await?;
if let Some(sync) = &savings_sync {
asset::apply_savings_sync(db, &mut session, household_id, sync).await?;
}
if let Some((kind, amount)) = carry {
let mut d = new_meta(household_id);
d.insert("from_period_id", period_id);
@@ -164,6 +173,7 @@ pub async fn close(
}
Ok(Closing {
savings_linked: matches!(savings_sync, Some(asset::SavingsSync::Update { .. })),
period: period::get(db, household_id, period_id).await?,
realized_savings: realized,
to_savings,
+223
View File
@@ -0,0 +1,223 @@
//! Dönem kapanışı → birikim: onayla TL varlığına ekleme, bekleyenler, yeniden kapatmada
//! bağlı kaydın güncellenmesi / silinmesi, birikimden çıkış varsa reddedilmesi.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::{Value, json};
/// Ekim: gelir 10.000, harcama 3.000 → gerçekleşen 7.000; `to_savings` ile kapatılır
async fn october(app: &TestApp, s: &Session, to_savings: i64) -> String {
let (_, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
)
.await;
let id = p["id"].as_str().unwrap().to_string();
app.post(
&format!("/periods/{id}/plan-items"),
s,
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
)
.await;
let (_, cats) = app.get("/categories", s).await;
let cat = cats[0]["id"].as_str().unwrap();
app.post(
&format!("/periods/{id}/entries"),
s,
json!({ "date": "2026-10-10", "amount": 300_000, "category_id": cat }),
)
.await;
close(app, s, &id, to_savings).await;
id
}
async fn close(app: &TestApp, s: &Session, id: &str, to_savings: i64) -> Value {
let (st, b) = app
.post(
&format!("/periods/{id}/close"),
s,
json!({ "to_savings": to_savings }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
b
}
async fn reopen(app: &TestApp, s: &Session, id: &str) {
let (st, b) = app
.post(&format!("/periods/{id}/reopen"), s, json!({}))
.await;
assert_eq!(st, StatusCode::OK, "{b}");
}
async fn cash(app: &TestApp, s: &Session) -> Value {
let (_, list) = app.get("/assets", s).await;
list.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "TRY")
.unwrap()
.clone()
}
async fn cash_txs(app: &TestApp, s: &Session) -> Vec<Value> {
let id = cash(app, s).await["id"].as_str().unwrap().to_string();
let (_, list) = app.get(&format!("/assets/{id}/transactions"), s).await;
list.as_array().unwrap().clone()
}
#[tokio::test]
async fn add_closing_savings_once() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let oct = october(&app, &s, 500_000).await;
let (_, pending) = app.get("/savings/pending", &s).await;
assert_eq!(
pending,
json!([{ "period_id": oct, "name": "Ekim 2026", "last_date": "2026-10-31", "to_savings": 500_000 }])
);
let (st, t) = app
.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::CREATED, "{t}");
assert_eq!(t["type"], "buy");
assert_eq!(t["total"], 500_000);
assert_eq!(t["date"], "2026-10-31");
assert_eq!(t["note"], "Ekim 2026 kapanışı");
assert_eq!(t["source_period_id"], oct.as_str());
assert_eq!(cash(&app, &s).await["cost"], 500_000);
let (st, b) = app
.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::CONFLICT, "{b}");
let (_, pending) = app.get("/savings/pending", &s).await;
assert_eq!(pending, json!([]));
// Açık dönem eklenemez; başka hane göremez
let (_, nov) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
)
.await;
let (st, _) = app
.post(
&format!("/periods/{}/to-savings", nov["id"].as_str().unwrap()),
&s,
json!({}),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
let other = app.register("Diger", None).await;
let (st, _) = app
.post(&format!("/periods/{oct}/to-savings"), &other, json!({}))
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (_, op) = app.get("/savings/pending", &other).await;
assert_eq!(op, json!([]));
// Silinen kayıt → dönem yeniden "bekleyen" olur
let (st, _) = app
.delete(
&format!("/asset-transactions/{}", t["id"].as_str().unwrap()),
&s,
)
.await;
assert_eq!(st, StatusCode::NO_CONTENT);
let (_, pending) = app.get("/savings/pending", &s).await;
assert_eq!(pending.as_array().unwrap().len(), 1);
app.cleanup().await;
}
#[tokio::test]
async fn reclosing_updates_or_removes_linked_savings() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let oct = october(&app, &s, 500_000).await;
app.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
.await;
// Yeni tutarla yeniden kapat → kayıt güncellenir
reopen(&app, &s, &oct).await;
let b = close(&app, &s, &oct, 400_000).await;
assert_eq!(b["savings_linked"], true);
let txs = cash_txs(&app, &s).await;
assert_eq!(txs.len(), 1);
assert_eq!(txs[0]["total"], 400_000);
assert_eq!(txs[0]["version"], 2);
assert_eq!(cash(&app, &s).await["cost"], 400_000);
// Birikim 0 → kayıt silinir; bekleyen de yok
reopen(&app, &s, &oct).await;
let b = close(&app, &s, &oct, 0).await;
assert_eq!(b["savings_linked"], false);
assert!(cash_txs(&app, &s).await.is_empty());
let (_, pending) = app.get("/savings/pending", &s).await;
assert_eq!(pending, json!([]));
// Kayıt yokken yeniden kapat → otomatik eklenmez, bekleyenlere düşer
reopen(&app, &s, &oct).await;
let b = close(&app, &s, &oct, 600_000).await;
assert_eq!(b["savings_linked"], false);
assert!(cash_txs(&app, &s).await.is_empty());
let (_, pending) = app.get("/savings/pending", &s).await;
assert_eq!(pending[0]["to_savings"], 600_000);
app.cleanup().await;
}
#[tokio::test]
async fn reclosing_is_refused_when_savings_were_spent() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let oct = october(&app, &s, 500_000).await;
app.post(&format!("/periods/{oct}/to-savings"), &s, json!({}))
.await;
// Birikimden 4.500 TL çıkış
let cash_id = cash(&app, &s).await["id"].as_str().unwrap().to_string();
let (st, _) = app
.post(
&format!("/assets/{cash_id}/transactions"),
&s,
json!({ "type": "sell", "date": "2026-11-05", "total": 450_000 }),
)
.await;
assert_eq!(st, StatusCode::CREATED);
reopen(&app, &s, &oct).await;
let (st, b) = app
.post(
&format!("/periods/{oct}/close"),
&s,
json!({ "to_savings": 300_000 }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT, "{b}");
assert!(
b["error"]["message"]
.as_str()
.unwrap()
.contains("TL çıkışları"),
"{b}"
);
// Hiçbir şey değişmedi: dönem düzeltmede, kayıt 5.000 TL
let (_, p) = app.get(&format!("/periods/{oct}"), &s).await;
assert_eq!(p["status"], "correcting");
let buy = cash_txs(&app, &s)
.await
.into_iter()
.find(|t| t["type"] == "buy")
.unwrap();
assert_eq!(buy["total"], 500_000);
// Yeterli tutarla kapanır
let b = close(&app, &s, &oct, 450_000).await;
assert_eq!(b["savings_linked"], true);
app.cleanup().await;
}