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 ### Elle kuru sil → otomatik kura dön
DELETE {{baseUrl}}/rates/BURAYA-KUR-ID 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 | //! | POST | `/api/assets/{id}/transactions` | alım / satım ekle |
//! | PATCH | `/api/asset-transactions/{id}` | işlemi düzenle | //! | PATCH | `/api/asset-transactions/{id}` | işlemi düzenle |
//! | DELETE | `/api/asset-transactions/{id}` | işlemi sil (soft) | //! | 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::{ use axum::{
Json, Json,
@@ -292,3 +294,48 @@ pub async fn delete_tx(
asset::delete_tx(&s.db, auth.household_id, path_id(&id)?).await?; asset::delete_tx(&s.db, auth.household_id, path_id(&id)?).await?;
Ok(StatusCode::NO_CONTENT) 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("/entries/{id}/restore", post(entry::restore))
.route("/assets", get(asset::list).post(asset::create)) .route("/assets", get(asset::list).post(asset::create))
.route("/portfolio", get(rates::portfolio)) .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", post(rates::create))
.route("/rates/{id}", delete(rates::delete)) .route("/rates/{id}", delete(rates::delete))
.route("/assets/{id}", patch(asset::update)) .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…" | //! | 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}/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}/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::{ use axum::{
Json, Json,
@@ -223,6 +224,9 @@ pub struct CloseResultDto {
to_savings: i64, to_savings: i64,
/// Sonraki döneme devreden; yoksa `null` /// Sonraki döneme devreden; yoksa `null`
carryover: Option<CarryoverDto>, 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)] #[derive(Serialize, TS)]
@@ -256,6 +260,7 @@ pub async fn close(
period: c.period.into(), period: c.period.into(),
realized_savings: c.realized_savings, realized_savings: c.realized_savings,
to_savings: c.to_savings, to_savings: c.to_savings,
savings_linked: c.savings_linked,
carryover: c.carryover.map(|x| CarryoverDto { carryover: c.carryover.map(|x| CarryoverDto {
kind: x.kind.as_str(), kind: x.kind.as_str(),
amount: x.amount, amount: x.amount,
+190
View File
@@ -429,3 +429,193 @@ fn clean_note(n: Option<&str>) -> AppResult<Option<String>> {
fn stale() -> AppError { fn stale() -> AppError {
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into()) 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}, common::{new_meta, now, scope},
period::{Period, PeriodStatus}, period::{Period, PeriodStatus},
}, },
services::{period, review}, services::{asset, period, review},
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -54,6 +54,8 @@ pub struct Closing {
pub realized_savings: i64, pub realized_savings: i64,
pub to_savings: i64, pub to_savings: i64,
pub carryover: Option<Carry>, 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. /// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime.
@@ -99,6 +101,9 @@ pub async fn close(
.realized_savings; .realized_savings;
let (to_savings, carry) = settle(realized, to_savings)?; let (to_savings, carry) = settle(realized, to_savings)?;
let next = next_period(db, household_id, &p).await?; 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 ts = now();
let mut session = db.client().start_session().await?; let mut session = db.client().start_session().await?;
@@ -138,6 +143,10 @@ pub async fn close(
.session(&mut session) .session(&mut session)
.await?; .await?;
if let Some(sync) = &savings_sync {
asset::apply_savings_sync(db, &mut session, household_id, sync).await?;
}
if let Some((kind, amount)) = carry { if let Some((kind, amount)) = carry {
let mut d = new_meta(household_id); let mut d = new_meta(household_id);
d.insert("from_period_id", period_id); d.insert("from_period_id", period_id);
@@ -164,6 +173,7 @@ pub async fn close(
} }
Ok(Closing { Ok(Closing {
savings_linked: matches!(savings_sync, Some(asset::SavingsSync::Update { .. })),
period: period::get(db, household_id, period_id).await?, period: period::get(db, household_id, period_id).await?,
realized_savings: realized, realized_savings: realized,
to_savings, 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;
}
+1
View File
@@ -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`). - 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. - 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`. - 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 ### 3.14 `exchange_rates` — Kurlar
```js ```js
+3 -1
View File
@@ -1,10 +1,11 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ 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 ✅. **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 ## 📌 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". - **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. - **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ı. - **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 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 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 + `<details>` "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 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 + `<details>` "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 "<ad> 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 ✅.**
+2 -2
View File
@@ -12,7 +12,7 @@
| **Faz 5** | Günlük defter | Harcama girişi | 🔄 | | **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 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 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 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 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 | ⏳ | | **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 - ✅ 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 - ✅ 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ı - ✅ 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
+4 -3
View File
@@ -11,7 +11,8 @@ import { dailyAllowance } from '@/lib/dashboard'
import { formatDayMonth, formatMoney, toIsoDate } from '@/lib/format' import { formatDayMonth, formatMoney, toIsoDate } from '@/lib/format'
import { weekIndexFor } from '@/lib/ledger' import { weekIndexFor } from '@/lib/ledger'
import { periodPath } from '@/lib/period-select' 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 { t } from '@/lib/strings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { GroupBudget } from '@/types/GroupBudget' import type { GroupBudget } from '@/types/GroupBudget'
@@ -27,7 +28,7 @@ const DashboardCharts = lazy(() => import('./charts'))
export function Overview({ period, writable }: { period: Period; writable: boolean }) { export function Overview({ period, writable }: { period: Period; writable: boolean }) {
const summary = useSummary(period.id) const summary = useSummary(period.id)
const budget = useBudget(period.id) const budget = useBudget(period.id)
const stats = useStats() const portfolio = usePortfolio()
const [today] = useState(() => toIsoDate(new Date())) const [today] = useState(() => toIsoDate(new Date()))
const failed = [summary, budget].find((q) => q.isError) const failed = [summary, budget].find((q) => q.isError)
@@ -52,7 +53,7 @@ export function Overview({ period, writable }: { period: Period; writable: boole
<Tile <Tile
emoji="🐷" emoji="🐷"
label={s.saved} label={s.saved}
amount={stats.data?.total_saved} amount={portfolio.data?.value_total}
hint={s.savedHint} hint={s.savedHint}
tone="text-income" tone="text-income"
/> />
@@ -13,6 +13,7 @@ import { SelectedPeriod } from '@/features/periods/selected-period'
import { StepActions, StepIntro, Stepper } from '@/features/period-wizard/wizard-ui' import { StepActions, StepIntro, Stepper } from '@/features/period-wizard/wizard-ui'
import { PeriodHeading } from '@/features/planning/plan-read' import { PeriodHeading } from '@/features/planning/plan-read'
import { formatDate, formatMoney, formatNumber, parseMoney, toIsoDate } from '@/lib/format' import { formatDate, formatMoney, formatNumber, parseMoney, toIsoDate } from '@/lib/format'
import { useAddPeriodSavings } from '@/lib/assets'
import { periodPath } from '@/lib/period-select' import { periodPath } from '@/lib/period-select'
import { useClosePeriod, useReview } from '@/lib/periods' import { useClosePeriod, useReview } from '@/lib/periods'
import { settlePreview } from '@/lib/review' import { settlePreview } from '@/lib/review'
@@ -308,6 +309,12 @@ function Result({ result: r }: { result: PeriodCloseResult }) {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{r.carryover ? (attached ? s.resultAttached : s.resultHint) : s.noCarry} {r.carryover ? (attached ? s.resultAttached : s.resultHint) : s.noCarry}
</p> </p>
{r.to_savings > 0 &&
(r.savings_linked ? (
<p className="text-sm text-muted-foreground">{s.savingsUpdated}</p>
) : (
<AddToSavings periodId={r.period.id} amount={r.to_savings} />
))}
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Button asChild size="lg"> <Button asChild size="lg">
<Link to={attached ? paths.planning : paths.periodWizard}> <Link to={attached ? paths.planning : paths.periodWizard}>
@@ -322,6 +329,39 @@ function Result({ result: r }: { result: PeriodCloseResult }) {
) )
} }
/** Kapanıştaki birikimi TL varlığına ekleme (onay = düğme) */
function AddToSavings({ periodId, amount }: { periodId: string; amount: number }) {
const add = useAddPeriodSavings()
const [error, setError] = useState<string>()
if (add.isSuccess) {
return (
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-income/10 p-3 text-sm">
<CircleCheck className="size-5 text-income" aria-hidden />
<span className="font-medium">{s.addedToSavings}</span>
<Link to={paths.savings} className="underline">
{s.toSavingsPage}
</Link>
</div>
)
}
return (
<div className="space-y-2 rounded-xl border p-3">
<p className="text-sm text-muted-foreground">{s.addToSavingsHint}</p>
<Button
variant="outline"
disabled={add.isPending}
onClick={() => {
setError(undefined)
add.mutate(periodId, { onError: (e) => setError(e.message) })
}}
>
{s.addToSavings(formatMoney(amount))}
</Button>
<FormError message={error} />
</div>
)
}
// ---------- ortak ---------- // ---------- ortak ----------
/** Gerçekleşen birikim → birikime aktarılan + devreden (önizleme ve sonuçta aynı görünüm) */ /** Gerçekleşen birikim → birikime aktarılan + devreden (önizleme ve sonuçta aynı görünüm) */
+2
View File
@@ -12,6 +12,7 @@ import { cn } from '@/lib/utils'
import type { Portfolio } from '@/types/Portfolio' import type { Portfolio } from '@/types/Portfolio'
import type { PortfolioItem } from '@/types/PortfolioItem' import type { PortfolioItem } from '@/types/PortfolioItem'
import { AddCurrency } from './add-currency' import { AddCurrency } from './add-currency'
import { PendingSavings } from './pending'
import { Notice, Pl, RateLine } from './parts' import { Notice, Pl, RateLine } from './parts'
const s = t.assets const s = t.assets
@@ -46,6 +47,7 @@ function SavingsView({ p }: { p: Portfolio }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PendingSavings />
<Summary p={p} /> <Summary p={p} />
{p.last_fetch === null ? ( {p.last_fetch === null ? (
<Notice tone="warning">{s.noFetch}</Notice> <Notice tone="warning">{s.noFetch}</Notice>
+51
View File
@@ -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 (
<section
aria-labelledby="pending-title"
className="rounded-2xl border-2 border-income/40 bg-income/5 p-4 sm:p-5"
>
<h2 id="pending-title" className="font-semibold">
<span aria-hidden>🐷</span> {s.pendingTitle}
</h2>
<p className="mb-2 text-sm text-muted-foreground">{s.pendingHint}</p>
<ul className="divide-y">
{pending.data.map((p) => (
<li key={p.period_id} className="flex items-center gap-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="font-medium">
{p.name} · <span className="money">{formatMoney(p.to_savings)}</span>
</p>
<p className="text-xs text-muted-foreground">
{s.pendingLine(formatDate(p.last_date))}
</p>
</div>
<Button
disabled={add.isPending}
onClick={() =>
add.mutate(p.period_id, {
onSuccess: () => toast.success(s.added(p.name)),
onError: (e) => toast.error(e.message),
})
}
>
{s.add}
</Button>
</li>
))}
</ul>
</section>
)
}
+15
View File
@@ -9,6 +9,7 @@ import type { AssetTransaction } from '@/types/AssetTransaction'
import type { AssetTxCreateRequest } from '@/types/AssetTxCreateRequest' import type { AssetTxCreateRequest } from '@/types/AssetTxCreateRequest'
import type { AssetTxUpdateRequest } from '@/types/AssetTxUpdateRequest' import type { AssetTxUpdateRequest } from '@/types/AssetTxUpdateRequest'
import type { AssetUpdateRequest } from '@/types/AssetUpdateRequest' import type { AssetUpdateRequest } from '@/types/AssetUpdateRequest'
import type { PendingSavings } from '@/types/PendingSavings'
import type { Portfolio } from '@/types/Portfolio' import type { Portfolio } from '@/types/Portfolio'
import type { Rate } from '@/types/Rate' import type { Rate } from '@/types/Rate'
import type { RateCreateRequest } from '@/types/RateCreateRequest' import type { RateCreateRequest } from '@/types/RateCreateRequest'
@@ -63,3 +64,17 @@ export const useSetRate = () =>
useAssetMutation((body: RateCreateRequest) => api.post<Rate>('/rates', body)) useAssetMutation((body: RateCreateRequest) => api.post<Rate>('/rates', body))
export const useDeleteRate = () => useAssetMutation((id: string) => api.delete(`/rates/${id}`)) 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<PendingSavings[]>('/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<AssetTransaction>(`/periods/${periodId}/to-savings`),
)
+2
View File
@@ -84,10 +84,12 @@ export function useClosePeriod() {
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: PeriodCloseRequest & { id: string }) => mutationFn: ({ id, ...body }: PeriodCloseRequest & { id: string }) =>
api.post<PeriodCloseResult>(`/periods/${id}/close`, body), api.post<PeriodCloseResult>(`/periods/${id}/close`, body),
// Yeniden kapatma birikimdeki bağlı kaydı da değiştirebilir → portföy de yenilenir
onSettled: () => onSettled: () =>
Promise.all([ Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.periods }), qc.invalidateQueries({ queryKey: queryKeys.periods }),
qc.invalidateQueries({ queryKey: ['period'] }), qc.invalidateQueries({ queryKey: ['period'] }),
qc.invalidateQueries({ queryKey: queryKeys.portfolio }),
]), ]),
}) })
} }
+1
View File
@@ -21,6 +21,7 @@ export const queryKeys = {
/** Birikimler: portföy ve varlık işlemleri bu önekle başlar (birlikte yenilenir) */ /** Birikimler: portföy ve varlık işlemleri bu önekle başlar (birlikte yenilenir) */
portfolio: ['portfolio'] as const, portfolio: ['portfolio'] as const,
assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const, assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const,
pendingSavings: ['portfolio', 'pending'] as const,
period: (id: string) => ['period', id] as const, period: (id: string) => ['period', id] as const,
planItems: (id: string) => ['period', id, 'plan-items'] as const, planItems: (id: string) => ['period', id, 'plan-items'] as const,
summary: (id: string) => ['period', id, 'summary'] as const, summary: (id: string) => ['period', id, 'summary'] as const,
+12 -1
View File
@@ -356,7 +356,7 @@ export const t = {
income: 'Toplam gelir', income: 'Toplam gelir',
fixed: 'Sabit giderler', fixed: 'Sabit giderler',
saved: 'Toplam birikim', saved: 'Toplam birikim',
savedHint: 'Kapanan dönemlerde birikime ayrılanlar', savedHint: 'Birikimlerin güncel değeri',
debt: 'Devreden borç', debt: 'Devreden borç',
debtHint: 'Önceki dönemden; harcanabilirden düşüldü', debtHint: 'Önceki dönemden; harcanabilirden düşüldü',
groupsTitle: 'Planlanan / gerçekleşen', groupsTitle: 'Planlanan / gerçekleşen',
@@ -487,6 +487,11 @@ export const t = {
activate: 'Etkinleştir', activate: 'Etkinleştir',
statusHint: 'Pasif varlık listede kalır ama yeni işlem alınmaz.', statusHint: 'Pasif varlık listede kalır ama yeni işlem alınmaz.',
notFound: 'Varlık bulunamadı.', 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: { periods: {
@@ -535,6 +540,12 @@ export const t = {
toDashboard: 'Panele dön', toDashboard: 'Panele dön',
noOpen: 'Kapatılacak açık dönem yok.', noOpen: 'Kapatılacak açık dönem yok.',
alreadyClosed: 'Bu dönem zaten kapalı.', 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: { common: {
+11
View File
@@ -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, };
+6 -1
View File
@@ -10,4 +10,9 @@ realized_savings: number, to_savings: number,
/** /**
* Sonraki döneme devreden; yoksa `null` * 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, };