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:
@@ -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
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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 + `<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
@@ -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
|
||||
|
||||
@@ -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
|
||||
<Tile
|
||||
emoji="🐷"
|
||||
label={s.saved}
|
||||
amount={stats.data?.total_saved}
|
||||
amount={portfolio.data?.value_total}
|
||||
hint={s.savedHint}
|
||||
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 { 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 }) {
|
||||
<p className="text-muted-foreground">
|
||||
{r.carryover ? (attached ? s.resultAttached : s.resultHint) : s.noCarry}
|
||||
</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">
|
||||
<Button asChild size="lg">
|
||||
<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 ----------
|
||||
|
||||
/** Gerçekleşen birikim → birikime aktarılan + devreden (önizleme ve sonuçta aynı görünüm) */
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<PendingSavings />
|
||||
<Summary p={p} />
|
||||
{p.last_fetch === null ? (
|
||||
<Notice tone="warning">{s.noFetch}</Notice>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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<Rate>('/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<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`),
|
||||
)
|
||||
|
||||
@@ -84,10 +84,12 @@ export function useClosePeriod() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: PeriodCloseRequest & { id: string }) =>
|
||||
api.post<PeriodCloseResult>(`/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 }),
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
|
||||
Reference in New Issue
Block a user