From 48554e0ff68c76496815b43a2b593934d2ef1ed0 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 13 Nov 2026 01:27:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(birikim):=20d=C3=B6nem=20kapan=C4=B1=C5=9F?= =?UTF-8?q?=C4=B1ndaki=20birikimi=20varl=C4=B1klara=20ekleme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/api.http | 6 + backend/src/routes/asset.rs | 47 ++++ backend/src/routes/mod.rs | 2 + backend/src/routes/period.rs | 5 + backend/src/services/asset.rs | 190 ++++++++++++++++ backend/src/services/closing.rs | 12 +- backend/tests/savings_link.rs | 223 +++++++++++++++++++ docs/DATA_MODEL.md | 1 + docs/MEMORY.md | 4 +- docs/PHASES.md | 4 +- frontend/src/features/dashboard/overview.tsx | 7 +- frontend/src/features/review/close-page.tsx | 40 ++++ frontend/src/features/savings/page.tsx | 2 + frontend/src/features/savings/pending.tsx | 51 +++++ frontend/src/lib/assets.ts | 15 ++ frontend/src/lib/periods.ts | 2 + frontend/src/lib/query.ts | 1 + frontend/src/lib/strings.ts | 13 +- frontend/src/types/PendingSavings.ts | 11 + frontend/src/types/PeriodCloseResult.ts | 7 +- 20 files changed, 634 insertions(+), 9 deletions(-) create mode 100644 backend/tests/savings_link.rs create mode 100644 frontend/src/features/savings/pending.tsx create mode 100644 frontend/src/types/PendingSavings.ts diff --git a/backend/api.http b/backend/api.http index fbd2b59..b921f98 100644 --- a/backend/api.http +++ b/backend/api.http @@ -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 diff --git a/backend/src/routes/asset.rs b/backend/src/routes/asset.rs index c0b7663..3a30d10 100644 --- a/backend/src/routes/asset.rs +++ b/backend/src/routes/asset.rs @@ -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, + auth: AuthUser, +) -> AppResult>> { + 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, + auth: AuthUser, + Path(id): Path, +) -> AppResult<(StatusCode, Json)> { + let t = + asset::add_period_savings(&s.db, auth.household_id, auth.user_id, path_id(&id)?).await?; + Ok((StatusCode::CREATED, Json(t.into()))) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 30a90f0..3ad9d0a 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -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)) diff --git a/backend/src/routes/period.rs b/backend/src/routes/period.rs index c271262..5d98c7a 100644 --- a/backend/src/routes/period.rs +++ b/backend/src/routes/period.rs @@ -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, + /// 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, diff --git a/backend/src/services/asset.rs b/backend/src/services/asset.rs index 438c7e8..14e2c69 100644 --- a/backend/src/services/asset.rs +++ b/backend/src/services/asset.rs @@ -429,3 +429,193 @@ fn clean_note(n: Option<&str>) -> AppResult> { 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> { + 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 { + 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 { + 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::(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> { + let linked: Vec = { + 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> { + 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 = 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::(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 { + 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), + )) +} diff --git a/backend/src/services/closing.rs b/backend/src/services/closing.rs index a01302d..6b44ee1 100644 --- a/backend/src/services/closing.rs +++ b/backend/src/services/closing.rs @@ -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, + /// 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, diff --git a/backend/tests/savings_link.rs b/backend/tests/savings_link.rs new file mode 100644 index 0000000..293a60e --- /dev/null +++ b/backend/tests/savings_link.rs @@ -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 { + 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; +} diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 45972f7..a6fdfab 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -178,6 +178,7 @@ Renkler ve ikonlar frontend tasarım token'larında. - Ortalama maliyet ve kâr/zarar bu kayıtlardan hesaplanır, saklanmaz: **ağırlıklı ortalama maliyet** (satışta çıkan maliyet = maliyet × satılan / eldeki; gerçekleşen kâr/zarar = satış tutarı − çıkan maliyet). Maliyet `total`'dan hesaplanır (birim fiyat bilgi amaçlı; verilmezse `total ÷ quantity`). - Tarih sırasıyla (aynı gün: ekleniş sırası) hiçbir anda eldeki miktar eksiye düşemez; ekleme, düzenleme ve silmede yeniden denetlenir. - TL varlığında `quantity_e6 = total × 10.000`, `unit_price_e6 = 1.000.000`. +- **Kapanıştan birikim:** kullanıcı onaylayınca (`POST /periods/{id}/to-savings`) dönemin `to_savings` tutarı TL varlığına `buy` olarak eklenir: tarih = dönemin son günü, `source_period_id` = dönem; dönem başına tek. Eklenmemiş kapalı dönemler `GET /savings/pending`. **Yeniden kapatmada** bağlı kayıt yeni tutar ve tarihe güncellenir (tutar 0 → silinir), kapatma transaction'ı içinde; TL'den sonraki çıkışlar yeni tutarı aşacaksa kapatma reddedilir (kullanıcı kararı "a", 2026-11-13). Bağlı kayıt yoksa otomatik eklenmez (onay gerekir). ### 3.14 `exchange_rates` — Kurlar ```js diff --git a/docs/MEMORY.md b/docs/MEMORY.md index f8fe823..dd9c5c2 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,10 +1,11 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 8 – Adım 4:** Kapanış entegrasyonu. Backend: `POST /periods/{id}/to-savings` (ya da kapanışta `add_to_savings: true`) → TL varlığına `buy` (`source_period_id`, tutar = `to_savings`, tarih = dönem son günü), **dönem başına tek** (tekrar → 409; yeniden kapatmada tutar değiştiyse kaydı güncelle); `GET /savings/pending` = kapalı, `to_savings > 0`, kaydı olmayan dönemler. Frontend: kapatma sihirbazı sonucunda "Birikime ekle" (onay) + Birikim sayfasında "Bekleyen birikimler" listesi; Panel "Toplam birikim" → `portfolio.value_total` (artık `stats.total_saved` değil; açıklama güncellenmeli). Yeniden açılıp tekrar kapanan dönem: bağlı alım güncellenir mi silinir mi → karar ver/sor. +**Faz 9 planı:** Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı. Önce `KAKEIBO_PROMPT.md` §8.8–8.9 (+ `templates/monthly_report.md`) oku; rapor biçimini kullanıcı verecek (açık konu). Kullanıcı şartı: raporlarda **tüm** kategorilerin dağılımı. Adımları PHASES'e yaz, onaylat. Faz 9'da Dokploy adımları da var (§12.2) — canlı kurulum zaten yapıldı. **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 +- **Yeniden kapatmada birikim (kullanıcı "a", 2026-11-13):** bağlı TL alımı yeni tutara güncellenir (0 → silinir). - **Faz 8 kararları (kullanıcı, 2026-11-13):** kur kaynakları TCMB (döviz) + xaus.com ons×TCMB USD (altın; çeyrek/yarım/tam has gram ile) + manuel; değerleme **alış kuru**; kapanış birikimi onayla TL varlığına alım (tekrar eklenmez), geçmiş kapanışlar "bekleyen birikimler". - **Kategori yönetimi ekranı (kullanıcı, 2026-11-13): Faz 10'da** (seçenek b). Faz 8 önce. - **Kur kaynağı araştırması (2026-11-13):** TCMB `today.xml` döviz var, **altın yok** (USD ForexBuying/Selling, 22 kod). xaus.com `/api/v1/spot` anahtarsız çalışıyor (`spot_usd_oz`, `per_gram_usd`), resmi değil → yedek: son kur + manuel. Truncgil doğrulanamadı. @@ -128,3 +129,4 @@ - **Adım 1:** Varlıklar backend: `models/asset.rs` (`AssetKind` cash/gold/fx, `TxType` buy/sell), `services/holding.rs` (saf: ağırlıklı ortalama maliyet, tam satışta artık maliyet sıfırlanır, `Oversold`, i128 `mul_div`; 5 birim test), `services/asset.rs` (liste + hesaplanmış durum, döviz ekleme 3 harf/tekil, ad/aktiflik, işlem ekle/düzenle/sil; her yazmada tarih sıralı yeniden denetim; TL: miktar = tutar), `routes/asset.rs` (7 uç nokta). Varsayılan 8 varlık `seed::assets` (kayıtta) + **migration v2** (eski haneler, idempotent → canlıdaki hane uygulama yeniden başlayınca alır). `tests/assets.rs` (4). Mutasyon 3/3 yakalandı. Backend 135 test. - **Adım 2:** Kurlar: `reqwest` 0.12 (`rustls-tls` → MongoDB ile aynı `ring`; 0.13 `aws-lc-rs` getiriyordu), `quick-xml` (serialize). `services/rate_sources.rs` (saf: `parse_tcmb` ForexBuying ÷ Unit, boş alış atlanır, `decimal_e6` kayan noktasız; `parse_gold` xaus `per_gram_usd`; `gold_rates` has gram katsayıları; testler örnek metinle), `services/rates.rs` (`refresh`: iki kaynak bağımsız, TCMB düşerse DB'deki son USD; gün başına upsert; `spawn_refresher` main'de, `RATES_REFRESH_MINUTES` varsayılan 60 / 0 kapalı; `current`: aggregation kod×(oto|hane) en yeni → en yeni tarih, eşitlikte manuel; `set_manual`/`delete_manual`; `portfolio` + `value_of` yarım yukarı). `GET /portfolio`, `POST /rates`, `DELETE /rates/{id}`. `calendar::today_tr` (UTC+3). Testler sahte axum sunucusuyla (internet yok): `tests/rates.rs` (2) + birim (4). Mutasyon: öncelik, USD yedeği yakalandı; yuvarlama kaçtı → `value_of` birim testi eklendi, artık yakalıyor. Gerçek kaynak denemesi (test DB): TCMB 22 döviz, altın 5 kod (gram 6.253,82, çeyrek 10.024,88 ₺). Backend 145 test. DEPLOY §2 + `.env.example`: `RATES_REFRESH_MINUTES`, konteynerin internete çıkışı gerekir. - **Adım 3:** Birikim sayfası (`features/savings/`): `page.tsx` (özet: toplam değer + son kur zamanı, maliyet, kâr/zarar `stacked`, gerçekleşen; uyarılar: kur hiç yok / 6 saattir çekilemedi / kursuz varlık; dağılım halka grafiği `allocation-chart.tsx` lazy 6 KB, renkli liste = metin özeti; varlık listesi: elde/geçmişi olanlar + `
` "Diğer varlıklar"; `add-currency.tsx`), `asset-page.tsx` (`/birikim/:id`: durum kartları, `RateBox` elle kur gir/kaldır, `TxFormView` `tx-form.tsx` alım/satım: miktar × birim fiyat → tutar önerisi, elle değişen tutar korunur, yeni alımda birim fiyat = güncel kur (kuruşa yuvarlı), TL'de yalnız tutar, satışta eldeki sınırı; geçmiş: düzenle + iki adımlı sil; pasife al/etkinleştir). `lib/decimal.ts` (`parseE6`: virgül ondalık, tek nokta + 3 rakam binlik, `formatE6`, `formatRate`, `valueKurus` BigInt = backend), `lib/savings.ts` (`plRatio`, `formatPlPercent` `-%0,6` ve ≈0 gizli, `isFetchStale` 6 saat, `rateAgeDays`, `splitAssets`, `allocation`, `kindEmoji`, `qty`), `lib/assets.ts` (kancalar, hepsi `['portfolio']` yeniler), `components/decimal-input.tsx`, `formatDateTime`. Frontend 114 test; e2e 37 kontrol (kurlar DB'ye elle, `RATES_REFRESH_MINUTES=0`). Düzeltilen: `%-0,0`, TL satırında tekrar, mobil özet taşması; Tailwind keyfi alt seçici (`[&>*]`) `lg:` ile ezilemedi → sınıflar öğenin kendisine. +- **Adım 4:** Kapanış → birikim. Backend (`services/asset.rs`): `add_period_savings` (yalnız `closed`, `to_savings > 0`, dönem başına tek → 409; TL varlığı pasif olsa da; not " kapanışı", tarih son gün), `pending_period_savings`, `plan_savings_sync` (transaction öncesi: bağlı kayıt + TL geçmişi denetimi → `SavingsSync::Update/Delete`) + `apply_savings_sync` (kapatma transaction'ı içinde). `PeriodCloseResult.savings_linked`. `GET /savings/pending`, `POST /periods/{id}/to-savings`. `tests/savings_link.rs` (3), mutasyon 3/3. Frontend: kapatma sonucunda `AddToSavings` (onay düğmesi → "Birikime eklendi" + bağlantı; yeniden kapatmada "kayıt güncellendi"), `features/savings/pending.tsx` (Birikimler üstünde), Panel "Toplam birikim" = `portfolio.value_total` (`stats.total_saved` artık arayüzde kullanılmıyor; backend'de duruyor). `useClosePeriod` portföyü de yeniler. Backend 149, frontend 114 test; e2e 14 kontrol. **Faz 8 ✅.** diff --git a/docs/PHASES.md b/docs/PHASES.md index b103245..ad15396 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -12,7 +12,7 @@ | **Faz 5** | Günlük defter | Harcama girişi | 🔄 | | **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ | | **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ | -| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | 🔄 | +| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ | | **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ⏳ | | **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ | | **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ | @@ -76,4 +76,4 @@ - ✅ 1. Backend: varlıklar (hane başına varsayılanlar: TL, gram/çeyrek/yarım/tam altın, ons, USD, EUR; döviz eklenebilir) + alım/satım kayıtları (satış ≤ eldeki miktar) + varlık başına miktar, **ağırlıklı ortalama maliyet**, gerçekleşen kâr/zarar; testler - ✅ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli - ✅ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı -- ⏳ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri +- ✅ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri diff --git a/frontend/src/features/dashboard/overview.tsx b/frontend/src/features/dashboard/overview.tsx index 9dff6bd..46fb098 100644 --- a/frontend/src/features/dashboard/overview.tsx +++ b/frontend/src/features/dashboard/overview.tsx @@ -11,7 +11,8 @@ import { dailyAllowance } from '@/lib/dashboard' import { formatDayMonth, formatMoney, toIsoDate } from '@/lib/format' import { weekIndexFor } from '@/lib/ledger' import { periodPath } from '@/lib/period-select' -import { useBudget, useStats, useSummary } from '@/lib/periods' +import { usePortfolio } from '@/lib/assets' +import { useBudget, useSummary } from '@/lib/periods' import { t } from '@/lib/strings' import { cn } from '@/lib/utils' import type { GroupBudget } from '@/types/GroupBudget' @@ -27,7 +28,7 @@ const DashboardCharts = lazy(() => import('./charts')) export function Overview({ period, writable }: { period: Period; writable: boolean }) { const summary = useSummary(period.id) const budget = useBudget(period.id) - const stats = useStats() + const portfolio = usePortfolio() const [today] = useState(() => toIsoDate(new Date())) const failed = [summary, budget].find((q) => q.isError) @@ -52,7 +53,7 @@ export function Overview({ period, writable }: { period: Period; writable: boole diff --git a/frontend/src/features/review/close-page.tsx b/frontend/src/features/review/close-page.tsx index 6d3bf07..edcdc47 100644 --- a/frontend/src/features/review/close-page.tsx +++ b/frontend/src/features/review/close-page.tsx @@ -13,6 +13,7 @@ import { SelectedPeriod } from '@/features/periods/selected-period' import { StepActions, StepIntro, Stepper } from '@/features/period-wizard/wizard-ui' import { PeriodHeading } from '@/features/planning/plan-read' import { formatDate, formatMoney, formatNumber, parseMoney, toIsoDate } from '@/lib/format' +import { useAddPeriodSavings } from '@/lib/assets' import { periodPath } from '@/lib/period-select' import { useClosePeriod, useReview } from '@/lib/periods' import { settlePreview } from '@/lib/review' @@ -308,6 +309,12 @@ function Result({ result: r }: { result: PeriodCloseResult }) {

{r.carryover ? (attached ? s.resultAttached : s.resultHint) : s.noCarry}

+ {r.to_savings > 0 && + (r.savings_linked ? ( +

{s.savingsUpdated}

+ ) : ( + + ))}
+ +
+ ) +} + // ---------- ortak ---------- /** Gerçekleşen birikim → birikime aktarılan + devreden (önizleme ve sonuçta aynı görünüm) */ diff --git a/frontend/src/features/savings/page.tsx b/frontend/src/features/savings/page.tsx index 2b7f963..d8ca737 100644 --- a/frontend/src/features/savings/page.tsx +++ b/frontend/src/features/savings/page.tsx @@ -12,6 +12,7 @@ import { cn } from '@/lib/utils' import type { Portfolio } from '@/types/Portfolio' import type { PortfolioItem } from '@/types/PortfolioItem' import { AddCurrency } from './add-currency' +import { PendingSavings } from './pending' import { Notice, Pl, RateLine } from './parts' const s = t.assets @@ -46,6 +47,7 @@ function SavingsView({ p }: { p: Portfolio }) { return (
+ {p.last_fetch === null ? ( {s.noFetch} diff --git a/frontend/src/features/savings/pending.tsx b/frontend/src/features/savings/pending.tsx new file mode 100644 index 0000000..a3cf7ce --- /dev/null +++ b/frontend/src/features/savings/pending.tsx @@ -0,0 +1,51 @@ +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { useAddPeriodSavings, usePendingSavings } from '@/lib/assets' +import { formatDate, formatMoney } from '@/lib/format' +import { t } from '@/lib/strings' + +const s = t.assets + +/** Kapanan dönemlerin henüz eklenmemiş birikimleri: tek dokunuşla TL alımı olarak ekle */ +export function PendingSavings() { + const pending = usePendingSavings() + const add = useAddPeriodSavings() + if (!pending.data?.length) return null + + return ( +
+

+ 🐷 {s.pendingTitle} +

+

{s.pendingHint}

+
    + {pending.data.map((p) => ( +
  • +
    +

    + {p.name} · {formatMoney(p.to_savings)} +

    +

    + {s.pendingLine(formatDate(p.last_date))} +

    +
    + +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/lib/assets.ts b/frontend/src/lib/assets.ts index 4180f51..67ad3ab 100644 --- a/frontend/src/lib/assets.ts +++ b/frontend/src/lib/assets.ts @@ -9,6 +9,7 @@ import type { AssetTransaction } from '@/types/AssetTransaction' import type { AssetTxCreateRequest } from '@/types/AssetTxCreateRequest' import type { AssetTxUpdateRequest } from '@/types/AssetTxUpdateRequest' import type { AssetUpdateRequest } from '@/types/AssetUpdateRequest' +import type { PendingSavings } from '@/types/PendingSavings' import type { Portfolio } from '@/types/Portfolio' import type { Rate } from '@/types/Rate' import type { RateCreateRequest } from '@/types/RateCreateRequest' @@ -63,3 +64,17 @@ export const useSetRate = () => useAssetMutation((body: RateCreateRequest) => api.post('/rates', body)) export const useDeleteRate = () => useAssetMutation((id: string) => api.delete(`/rates/${id}`)) + +/** Kapanıp birikime henüz eklenmemiş dönemler */ +export function usePendingSavings() { + return useQuery({ + queryKey: queryKeys.pendingSavings, + queryFn: () => api.get('/savings/pending'), + }) +} + +/** Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez) */ +export const useAddPeriodSavings = () => + useAssetMutation((periodId: string) => + api.post(`/periods/${periodId}/to-savings`), + ) diff --git a/frontend/src/lib/periods.ts b/frontend/src/lib/periods.ts index 738db92..3f65027 100644 --- a/frontend/src/lib/periods.ts +++ b/frontend/src/lib/periods.ts @@ -84,10 +84,12 @@ export function useClosePeriod() { return useMutation({ mutationFn: ({ id, ...body }: PeriodCloseRequest & { id: string }) => api.post(`/periods/${id}/close`, body), + // Yeniden kapatma birikimdeki bağlı kaydı da değiştirebilir → portföy de yenilenir onSettled: () => Promise.all([ qc.invalidateQueries({ queryKey: queryKeys.periods }), qc.invalidateQueries({ queryKey: ['period'] }), + qc.invalidateQueries({ queryKey: queryKeys.portfolio }), ]), }) } diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index 73f66c4..882c2ff 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -21,6 +21,7 @@ export const queryKeys = { /** Birikimler: portföy ve varlık işlemleri bu önekle başlar (birlikte yenilenir) */ portfolio: ['portfolio'] as const, assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const, + pendingSavings: ['portfolio', 'pending'] as const, period: (id: string) => ['period', id] as const, planItems: (id: string) => ['period', id, 'plan-items'] as const, summary: (id: string) => ['period', id, 'summary'] as const, diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index ebe2e37..af13600 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -356,7 +356,7 @@ export const t = { income: 'Toplam gelir', fixed: 'Sabit giderler', saved: 'Toplam birikim', - savedHint: 'Kapanan dönemlerde birikime ayrılanlar', + savedHint: 'Birikimlerin güncel değeri', debt: 'Devreden borç', debtHint: 'Önceki dönemden; harcanabilirden düşüldü', groupsTitle: 'Planlanan / gerçekleşen', @@ -487,6 +487,11 @@ export const t = { activate: 'Etkinleştir', statusHint: 'Pasif varlık listede kalır ama yeni işlem alınmaz.', notFound: 'Varlık bulunamadı.', + pendingTitle: 'Bekleyen birikimler', + pendingHint: 'Kapanan dönemlerde birikime ayırdığın ama buraya henüz eklenmemiş tutarlar.', + pendingLine: (date: string) => `${date} tarihli TL alımı olarak eklenir`, + add: 'Ekle', + added: (name: string) => `${name} birikimi eklendi`, }, periods: { @@ -535,6 +540,12 @@ export const t = { toDashboard: 'Panele dön', noOpen: 'Kapatılacak açık dönem yok.', alreadyClosed: 'Bu dönem zaten kapalı.', + addToSavings: (x: string) => `🐷 Birikime ekle (${x})`, + addToSavingsHint: + 'Onaylarsan bu tutar Birikimler’de, dönemin son günü tarihli Türk Lirası alımı olarak görünür.', + addedToSavings: 'Birikime eklendi', + toSavingsPage: 'Birikimlere git', + savingsUpdated: 'Birikimlerdeki kayıt yeni tutarla güncellendi.', }, common: { diff --git a/frontend/src/types/PendingSavings.ts b/frontend/src/types/PendingSavings.ts new file mode 100644 index 0000000..95e6b14 --- /dev/null +++ b/frontend/src/types/PendingSavings.ts @@ -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 PendingSavings = { period_id: string, name: string, +/** + * Dönemin son günü (eklenecek alımın tarihi) + */ +last_date: string, +/** + * Kuruş + */ +to_savings: number, }; diff --git a/frontend/src/types/PeriodCloseResult.ts b/frontend/src/types/PeriodCloseResult.ts index deb806d..bc52897 100644 --- a/frontend/src/types/PeriodCloseResult.ts +++ b/frontend/src/types/PeriodCloseResult.ts @@ -10,4 +10,9 @@ realized_savings: number, to_savings: number, /** * Sonraki döneme devreden; yoksa `null` */ -carryover: Carryover | null, }; +carryover: Carryover | null, +/** + * 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: boolean, };