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
+47
View File
@@ -9,6 +9,8 @@
//! | POST | `/api/assets/{id}/transactions` | alım / satım ekle |
//! | PATCH | `/api/asset-transactions/{id}` | işlemi düzenle |
//! | DELETE | `/api/asset-transactions/{id}` | işlemi sil (soft) |
//! | GET | `/api/savings/pending` | kapanıp birikime henüz eklenmemiş dönemler |
//! | POST | `/api/periods/{id}/to-savings` | dönemin birikimini TL varlığına alım olarak ekle |
use axum::{
Json,
@@ -292,3 +294,48 @@ pub async fn delete_tx(
asset::delete_tx(&s.db, auth.household_id, path_id(&id)?).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PendingSavings")]
pub struct PendingDto {
period_id: String,
name: String,
/// Dönemin son günü (eklenecek alımın tarihi)
last_date: String,
/// Kuruş
#[ts(type = "number")]
to_savings: i64,
}
pub async fn pending(
State(s): State<AppState>,
auth: AuthUser,
) -> AppResult<Json<Vec<PendingDto>>> {
let list = asset::pending_period_savings(&s.db, auth.household_id).await?;
Ok(Json(
list.into_iter()
.map(|p| {
let end = crate::services::calendar::parse_date(&p.end_date)
.expect("geçerli bitiş tarihi");
PendingDto {
period_id: p.id.to_string(),
name: p.name,
last_date: crate::services::calendar::format_date(
end - time::Duration::days(1),
),
to_savings: p.to_savings.unwrap_or(0),
}
})
.collect(),
))
}
pub async fn add_period_savings(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<(StatusCode, Json<TxDto>)> {
let t =
asset::add_period_savings(&s.db, auth.household_id, auth.user_id, path_id(&id)?).await?;
Ok((StatusCode::CREATED, Json(t.into())))
}
+2
View File
@@ -102,6 +102,8 @@ pub fn router(state: AppState) -> Router {
.route("/entries/{id}/restore", post(entry::restore))
.route("/assets", get(asset::list).post(asset::create))
.route("/portfolio", get(rates::portfolio))
.route("/savings/pending", get(asset::pending))
.route("/periods/{id}/to-savings", post(asset::add_period_savings))
.route("/rates", post(rates::create))
.route("/rates/{id}", delete(rates::delete))
.route("/assets/{id}", patch(asset::update))
+5
View File
@@ -9,6 +9,7 @@
//! | PATCH | `/api/periods/{id}` | ad / tarihler / birikim hedefi / "Nasıl daha iyi…" |
//! | POST | `/api/periods/{id}/close` | dönemi kapat: devir + birikime aktarılan (transaction) |
//! | POST | `/api/periods/{id}/reopen` | **sahip:** kapalı dönemi düzeltmeye aç (`correcting`) |
//! | POST | `/api/periods/{id}/to-savings` | kapanıştaki birikimi TL varlığına ekle (bkz. `routes/asset.rs`) |
use axum::{
Json,
@@ -223,6 +224,9 @@ pub struct CloseResultDto {
to_savings: i64,
/// Sonraki döneme devreden; yoksa `null`
carryover: Option<CarryoverDto>,
/// Birikimlerde bu döneme bağlı kayıt var (yeniden kapatmada yeni tutara güncellendi).
/// `false` ve `to_savings > 0` → arayüz "Birikime ekle" önerir.
savings_linked: bool,
}
#[derive(Serialize, TS)]
@@ -256,6 +260,7 @@ pub async fn close(
period: c.period.into(),
realized_savings: c.realized_savings,
to_savings: c.to_savings,
savings_linked: c.savings_linked,
carryover: c.carryover.map(|x| CarryoverDto {
kind: x.kind.as_str(),
amount: x.amount,
+190
View File
@@ -429,3 +429,193 @@ fn clean_note(n: Option<&str>) -> AppResult<Option<String>> {
fn stale() -> AppError {
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
// ---------- Dönem kapanışından birikim ----------
/// Kapanışta birikime aktarılan tutarın TL varlığına eklenmiş kaydı (dönem başına tek)
pub async fn period_savings_tx(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Option<AssetTx>> {
let mut f = scope(household_id);
f.insert("source_period_id", period_id);
Ok(txs(db).find_one(f).await?)
}
async fn cash_asset(db: &Database, household_id: Uuid) -> AppResult<Asset> {
let mut f = scope(household_id);
f.insert("kind", "cash");
assets(db)
.find_one(f)
.await?
.ok_or_else(|| AppError::Internal("hanenin TL varlığı yok".into()))
}
/// Kapalı dönemin `to_savings` tutarını TL varlığına **alım** olarak ekler (kullanıcı onayıyla).
/// Tarih = dönemin son günü. Aynı dönem ikinci kez eklenemez.
pub async fn add_period_savings(
db: &Database,
household_id: Uuid,
user_id: Uuid,
period_id: Uuid,
) -> AppResult<AssetTx> {
let p = crate::services::period::get(db, household_id, period_id).await?;
if p.status != crate::models::period::PeriodStatus::Closed {
return Err(AppError::BadRequest(
"Dönem kapanmadan birikime eklenemez".into(),
));
}
let amount = p.to_savings.unwrap_or(0);
if amount <= 0 {
return Err(AppError::BadRequest(
"Bu dönemde birikime aktarılan tutar yok".into(),
));
}
if period_savings_tx(db, household_id, period_id)
.await?
.is_some()
{
return Err(AppError::Conflict(
"Bu dönemin birikimi zaten eklendi".into(),
));
}
let cash = cash_asset(db, household_id).await?;
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("asset_id", cash.id);
d.insert("type", TxType::Buy.as_str());
d.insert("date", last_day(&p.end_date)?);
d.insert("quantity_e6", amount * 10_000);
d.insert("unit_price_e6", ONE_E6);
d.insert("total", amount);
d.insert("note", format!("{} kapanışı", p.name));
d.insert("source_period_id", period_id);
d.insert("created_by", user_id);
db.collection::<Document>(col::ASSET_TRANSACTIONS)
.insert_one(d)
.await?;
txs(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen işlem okunamadı".into()))
}
/// Kapalı, birikime aktarılan tutarı olan ama henüz eklenmemiş dönemler (en yeni başta)
pub async fn pending_period_savings(
db: &Database,
household_id: Uuid,
) -> AppResult<Vec<crate::models::period::Period>> {
let linked: Vec<Uuid> = {
let mut f = scope(household_id);
f.insert("source_period_id", doc! { "$ne": null });
let mut c = txs(db).find(f).await?;
let mut v = Vec::new();
while c.advance().await? {
if let Some(p) = c.deserialize_current()?.source_period_id {
v.push(p);
}
}
v
};
Ok(crate::services::period::list(db, household_id)
.await?
.into_iter()
.filter(|p| {
p.status == crate::models::period::PeriodStatus::Closed
&& p.to_savings.unwrap_or(0) > 0
&& !linked.contains(&p.id)
})
.collect())
}
/// Yeniden kapatmada bağlı kayda yapılacak iş (transaction'dan önce hazırlanıp denetlenir)
pub enum SavingsSync {
Update { id: Uuid, amount: i64, date: String },
Delete { id: Uuid },
}
/// Bağlı kayıt varsa yeni tutar/tarihe göre işi hazırlar. Tutar 0 → silinir.
/// TL'de sonraki satışlar eldeki miktarı aşacaksa reddedilir (kapatma da yapılmaz).
pub async fn plan_savings_sync(
db: &Database,
household_id: Uuid,
period_id: Uuid,
amount: i64,
end_date: &str,
) -> AppResult<Option<SavingsSync>> {
let Some(linked) = period_savings_tx(db, household_id, period_id).await? else {
return Ok(None);
};
let date = last_day(end_date)?;
let mut list: Vec<AssetTx> = all_txs(db, household_id, Some(linked.asset_id))
.await?
.into_iter()
.filter(|x| x.id != linked.id)
.collect();
if amount > 0 {
let mut changed = linked.clone();
changed.date = date.clone();
changed.quantity_e6 = amount * 10_000;
changed.total = amount;
let pos = list.partition_point(|x| {
(x.date.as_str(), x.id.bytes()) <= (date.as_str(), linked.id.bytes())
});
list.insert(pos, changed);
}
if holding::holding(&moves(&list)).is_err() {
return Err(AppError::Conflict(
"Birikimden yapılan TL çıkışları yeni tutarı aşıyor; önce Birikimler'deki kayıtları düzeltin"
.into(),
));
}
Ok(Some(if amount > 0 {
SavingsSync::Update {
id: linked.id,
amount,
date,
}
} else {
SavingsSync::Delete { id: linked.id }
}))
}
/// Hazırlanan işi kapatma transaction'ı içinde uygular
pub async fn apply_savings_sync(
db: &Database,
session: &mut mongodb::ClientSession,
household_id: Uuid,
sync: &SavingsSync,
) -> AppResult<()> {
let ts = now();
let (id, set) = match sync {
SavingsSync::Update { id, amount, date } => (
*id,
doc! {
"date": date,
"quantity_e6": amount * 10_000,
"unit_price_e6": ONE_E6,
"total": amount,
"updated_at": ts,
},
),
SavingsSync::Delete { id } => (*id, doc! { "deleted_at": ts, "updated_at": ts }),
};
let mut f = scope(household_id);
f.insert("_id", id);
db.collection::<Document>(col::ASSET_TRANSACTIONS)
.update_one(f, doc! { "$set": set, "$inc": { "version": 1 } })
.session(session)
.await?;
Ok(())
}
/// Hariç bitiş → son gün (dahil)
fn last_day(end_date: &str) -> AppResult<String> {
let end =
parse_date(end_date).ok_or_else(|| AppError::Internal("dönem bitişi geçersiz".into()))?;
Ok(crate::services::calendar::format_date(
end - time::Duration::days(1),
))
}
+11 -1
View File
@@ -25,7 +25,7 @@ use crate::{
common::{new_meta, now, scope},
period::{Period, PeriodStatus},
},
services::{period, review},
services::{asset, period, review},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -54,6 +54,8 @@ pub struct Closing {
pub realized_savings: i64,
pub to_savings: i64,
pub carryover: Option<Carry>,
/// Birikime eklenmiş kayıt var (yeniden kapatmada güncellendi)
pub savings_linked: bool,
}
/// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime.
@@ -99,6 +101,9 @@ pub async fn close(
.realized_savings;
let (to_savings, carry) = settle(realized, to_savings)?;
let next = next_period(db, household_id, &p).await?;
// Yeniden kapatma: birikime eklenmiş kayıt varsa yeni tutara uyarlanır (önce denetlenir)
let savings_sync =
asset::plan_savings_sync(db, household_id, period_id, to_savings, &p.end_date).await?;
let ts = now();
let mut session = db.client().start_session().await?;
@@ -138,6 +143,10 @@ pub async fn close(
.session(&mut session)
.await?;
if let Some(sync) = &savings_sync {
asset::apply_savings_sync(db, &mut session, household_id, sync).await?;
}
if let Some((kind, amount)) = carry {
let mut d = new_meta(household_id);
d.insert("from_period_id", period_id);
@@ -164,6 +173,7 @@ pub async fn close(
}
Ok(Closing {
savings_linked: matches!(savings_sync, Some(asset::SavingsSync::Update { .. })),
period: period::get(db, household_id, period_id).await?,
realized_savings: realized,
to_savings,