//! Birikimler / varlıklar ve alım-satım kayıtları (KAKEIBO_PROMPT §8.7, DATA_MODEL.md §3.12–3.13). //! //! Kurallar: //! - Varsayılan varlıklar hane açılınca gelir (TL, altınlar, USD, EUR); kullanıcı **döviz** ekleyebilir. //! - Miktar/fiyat ölçekli tam sayı (`_e6`), tutar kuruş. TL varlığında miktar = tutar, birim fiyat 1. //! - Eldeki miktar, maliyet ve kâr/zarar saklanmaz; işlemlerden hesaplanır (`holding`). //! - Ekleme, düzenleme ve silmede tüm işlemler **tarih sırasıyla** yeniden denetlenir: //! hiçbir tarihte eldekinden fazla satılmış olamaz (geriye tarihli kayıt / alım silme dahil). //! - Silme soft delete. Hanenin tüm üyeleri yazabilir. use std::collections::HashMap; use mongodb::{ Database, bson::{Bson, Document, Uuid, doc}, options::ReturnDocument, }; use crate::{ db::col, error::{AppError, AppResult}, models::{ asset::{Asset, AssetKind, AssetTx, TxType}, common::{new_meta, now, scope}, }, services::{ calendar::parse_date, holding::{self, Holding, Move, Oversold}, period::MAX_AMOUNT, }, }; /// Not üst sınırı (karakter) pub const MAX_NOTE: usize = 200; /// Miktar ve birim fiyat üst sınırı (e6): 1 milyar birim / 1 milyar TL const MAX_E6: i64 = 1_000_000_000_000_000; /// TL varlığında birim fiyat (1 TL) const ONE_E6: i64 = 1_000_000; pub struct NewTx { pub tx_type: TxType, pub date: String, /// TL varlığında yok sayılır (tutardan türetilir) pub quantity_e6: i64, /// Verilmezse tutar ÷ miktar pub unit_price_e6: Option, pub total: i64, pub note: Option, } #[derive(Default)] pub struct TxChanges { pub date: Option, pub quantity_e6: Option, pub unit_price_e6: Option, pub total: Option, /// `Some("")` → notu kaldır pub note: Option, pub version: Option, } fn assets(db: &Database) -> mongodb::Collection { db.collection(col::ASSETS) } fn txs(db: &Database) -> mongodb::Collection { db.collection(col::ASSET_TRANSACTIONS) } // ---------- Varlıklar ---------- /// Hanenin varlıkları (sıralı) ve her birinin hesaplanmış durumu + işlem sayısı pub async fn list(db: &Database, household_id: Uuid) -> AppResult> { let mut cursor = assets(db) .find(scope(household_id)) .sort(doc! { "sort_order": 1, "_id": 1 }) .await?; let mut list = Vec::new(); while cursor.advance().await? { list.push(cursor.deserialize_current()?); } let mut by_asset: HashMap> = HashMap::new(); for t in all_txs(db, household_id, None).await? { by_asset.entry(t.asset_id).or_default().push(t); } list.into_iter() .map(|a: Asset| { let ts = by_asset.remove(&a.id).unwrap_or_default(); let h = holding::holding(&moves(&ts)) .map_err(|_| AppError::Internal(format!("varlık {} eksi bakiyede", a.id)))?; Ok((a, h, ts.len())) }) .collect() } pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult { let mut f = scope(household_id); f.insert("_id", id); assets(db).find_one(f).await?.ok_or(AppError::NotFound) } /// Kullanıcının eklediği döviz (ör. GBP). Kod hanede tekil. pub async fn create( db: &Database, household_id: Uuid, code: &str, name: &str, unit_label: Option<&str>, ) -> AppResult { let code = code.trim().to_uppercase(); if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) { return Err(AppError::BadRequest( "Döviz kodu 3 harf olmalı (ör. GBP)".into(), )); } let name = clean_name(name)?; let mut f = scope(household_id); f.insert("code", &code); if assets(db).find_one(f).await?.is_some() { return Err(AppError::Conflict("Bu varlık zaten var".into())); } let last = assets(db) .find_one(scope(household_id)) .sort(doc! { "sort_order": -1 }) .await? .map_or(0, |a| a.sort_order); let mut d = new_meta(household_id); let id = d.get("_id").cloned().unwrap_or(Bson::Null); d.insert("kind", "fx"); d.insert("code", &code); d.insert("name", name); d.insert( "unit_label", unit_label .map(str::trim) .filter(|u| !u.is_empty()) .unwrap_or(&code), ); d.insert("sort_order", last + 10); d.insert("is_active", true); db.collection::(col::ASSETS).insert_one(d).await?; assets(db) .find_one(doc! { "_id": id }) .await? .ok_or_else(|| AppError::Internal("eklenen varlık okunamadı".into())) } /// Ad ve aktiflik. Pasif varlık listede kalır (geçmiş işlemler) ama yeni işlem alınmaz. pub async fn update( db: &Database, household_id: Uuid, id: Uuid, name: Option, is_active: Option, version: Option, ) -> AppResult { let current = get(db, household_id, id).await?; if version.is_some_and(|v| v != current.version) { return Err(stale()); } let mut set = doc! { "updated_at": now() }; if let Some(n) = name { set.insert("name", clean_name(&n)?); } if let Some(a) = is_active { set.insert("is_active", a); } let mut f = scope(household_id); f.insert("_id", id); f.insert("version", current.version); assets(db) .find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } }) .return_document(ReturnDocument::After) .await? .ok_or_else(stale) } // ---------- İşlemler ---------- /// Varlığın işlemleri, en yeni başta pub async fn list_txs( db: &Database, household_id: Uuid, asset_id: Uuid, ) -> AppResult> { get(db, household_id, asset_id).await?; let mut list = all_txs(db, household_id, Some(asset_id)).await?; list.reverse(); Ok(list) } pub async fn create_tx( db: &Database, household_id: Uuid, user_id: Uuid, asset_id: Uuid, t: NewTx, ) -> AppResult { let asset = get(db, household_id, asset_id).await?; if !asset.is_active { return Err(AppError::BadRequest( "Bu varlık pasif; önce etkinleştirin".into(), )); } let date = clean_date(&t.date)?; let (quantity_e6, unit_price_e6, total) = clean_amounts(&asset, t.quantity_e6, t.unit_price_e6, t.total)?; let note = clean_note(t.note.as_deref())?; // Yeni kayıt dahil sıralı denetim (yeni kayıt aynı günün sonuna eklenir) let list = all_txs(db, household_id, Some(asset_id)).await?; let pos = list.partition_point(|x| x.date <= date); let mut ms = moves(&list); ms.insert( pos, Move { tx_type: t.tx_type, quantity_e6, total, }, ); check(&ms)?; let mut d = new_meta(household_id); let id = d.get("_id").cloned().unwrap_or(Bson::Null); d.insert("asset_id", asset_id); d.insert("type", t.tx_type.as_str()); d.insert("date", date); d.insert("quantity_e6", quantity_e6); d.insert("unit_price_e6", unit_price_e6); d.insert("total", total); d.insert("note", note.map_or(Bson::Null, Bson::String)); d.insert("source_period_id", Bson::Null); d.insert("created_by", user_id); db.collection::(col::ASSET_TRANSACTIONS) .insert_one(d) .await?; txs(db) .find_one(doc! { "_id": id }) .await? .ok_or_else(|| AppError::Internal("eklenen işlem okunamadı".into())) } pub async fn update_tx( db: &Database, household_id: Uuid, id: Uuid, ch: TxChanges, ) -> AppResult { let current = get_tx(db, household_id, id).await?; if ch.version.is_some_and(|v| v != current.version) { return Err(stale()); } let asset = get(db, household_id, current.asset_id).await?; let date = match ch.date { Some(d) => clean_date(&d)?, None => current.date.clone(), }; // Miktar ya da tutar değiştiyse birim fiyat (verilmediyse) yeniden hesaplanır let amounts_changed = ch.quantity_e6.is_some() || ch.total.is_some(); let (quantity_e6, unit_price_e6, total) = clean_amounts( &asset, ch.quantity_e6.unwrap_or(current.quantity_e6), ch.unit_price_e6 .or((!amounts_changed).then_some(current.unit_price_e6)), ch.total.unwrap_or(current.total), )?; // Değişmiş haliyle sıralı denetim let mut list: Vec = all_txs(db, household_id, Some(current.asset_id)) .await? .into_iter() .filter(|x| x.id != id) .collect(); let mut changed = current.clone(); changed.date = date.clone(); changed.quantity_e6 = quantity_e6; changed.total = total; // MongoDB `_id` (UUID) sırası bayt sırasıdır let pos = list.partition_point(|x| (x.date.as_str(), x.id.bytes()) <= (date.as_str(), id.bytes())); list.insert(pos, changed); check(&moves(&list))?; let mut set = doc! { "updated_at": now(), "date": date, "quantity_e6": quantity_e6, "unit_price_e6": unit_price_e6, "total": total, }; if let Some(n) = ch.note { set.insert( "note", clean_note(Some(&n))?.map_or(Bson::Null, Bson::String), ); } let mut f = scope(household_id); f.insert("_id", id); f.insert("version", current.version); txs(db) .find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } }) .return_document(ReturnDocument::After) .await? .ok_or_else(stale) } /// Soft delete. Bir alımı silmek sonraki satışları eksiye düşürecekse reddedilir. pub async fn delete_tx(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> { let current = get_tx(db, household_id, id).await?; let rest: Vec = all_txs(db, household_id, Some(current.asset_id)) .await? .into_iter() .filter(|x| x.id != id) .collect(); if holding::holding(&moves(&rest)).is_err() { return Err(AppError::Conflict( "Bu alım silinirse sonraki satışlar eldeki miktarı aşar; önce satışı düzeltin".into(), )); } let ts = now(); let mut f = scope(household_id); f.insert("_id", id); txs(db) .update_one( f, doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } }, ) .await?; Ok(()) } // ---------- yardımcılar ---------- async fn get_tx(db: &Database, household_id: Uuid, id: Uuid) -> AppResult { let mut f = scope(household_id); f.insert("_id", id); txs(db).find_one(f).await?.ok_or(AppError::NotFound) } /// Hanenin (ya da bir varlığın) işlemleri, **tarih sırasıyla** (aynı gün: ekleniş sırası; /// UUID v7 zamana göre sıralı) async fn all_txs( db: &Database, household_id: Uuid, asset_id: Option, ) -> AppResult> { let mut f = scope(household_id); if let Some(a) = asset_id { f.insert("asset_id", a); } let mut cursor = txs(db).find(f).sort(doc! { "date": 1, "_id": 1 }).await?; let mut out = Vec::new(); while cursor.advance().await? { out.push(cursor.deserialize_current()?); } Ok(out) } fn moves(list: &[AssetTx]) -> Vec { list.iter() .map(|t| Move { tx_type: t.tx_type, quantity_e6: t.quantity_e6, total: t.total, }) .collect() } fn check(ms: &[Move]) -> AppResult<()> { holding::holding(ms).map(|_| ()).map_err(|Oversold(_)| { AppError::BadRequest("Satış miktarı o tarihte eldeki miktarı aşıyor".into()) }) } /// (miktar, birim fiyat, tutar). TL varlığında miktar = tutar, fiyat 1. fn clean_amounts( asset: &Asset, quantity_e6: i64, unit_price_e6: Option, total: i64, ) -> AppResult<(i64, i64, i64)> { if total <= 0 || total > MAX_AMOUNT { return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into())); } if asset.kind == AssetKind::Cash { // kuruş → TL × 1e6 return Ok((total * 10_000, ONE_E6, total)); } if quantity_e6 <= 0 || quantity_e6 > MAX_E6 { return Err(AppError::BadRequest("Miktar sıfırdan büyük olmalı".into())); } let price = match unit_price_e6 { Some(p) if p <= 0 || p > MAX_E6 => { return Err(AppError::BadRequest("Birim fiyat geçersiz".into())); } Some(p) => p, None => holding::unit_price_e6(total, quantity_e6), }; Ok((quantity_e6, price, total)) } fn clean_date(s: &str) -> AppResult { let s = s.trim(); parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?; Ok(s.to_string()) } fn clean_name(s: &str) -> AppResult { let s = s.trim(); if s.is_empty() || s.chars().count() > 60 { return Err(AppError::BadRequest("Ad 1–60 karakter olmalı".into())); } Ok(s.to_string()) } fn clean_note(n: Option<&str>) -> AppResult> { match n.map(str::trim) { None | Some("") => Ok(None), Some(s) if s.chars().count() > MAX_NOTE => Err(AppError::BadRequest(format!( "Not en fazla {MAX_NOTE} karakter olabilir" ))), Some(s) => Ok(Some(s.to_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> { let mut f = scope(household_id); f.insert("source_period_id", period_id); Ok(txs(db).find_one(f).await?) } async fn cash_asset(db: &Database, household_id: Uuid) -> AppResult { let mut f = scope(household_id); f.insert("kind", "cash"); assets(db) .find_one(f) .await? .ok_or_else(|| AppError::Internal("hanenin TL varlığı yok".into())) } /// Kapalı dönemin `to_savings` tutarını TL varlığına **alım** olarak ekler (kullanıcı onayıyla). /// Tarih = dönemin son günü. Aynı dönem ikinci kez eklenemez. pub async fn add_period_savings( db: &Database, household_id: Uuid, user_id: Uuid, period_id: Uuid, ) -> AppResult { let p = crate::services::period::get(db, household_id, period_id).await?; if p.status != crate::models::period::PeriodStatus::Closed { return Err(AppError::BadRequest( "Dönem kapanmadan birikime eklenemez".into(), )); } let amount = p.to_savings.unwrap_or(0); if amount <= 0 { return Err(AppError::BadRequest( "Bu dönemde birikime aktarılan tutar yok".into(), )); } if period_savings_tx(db, household_id, period_id) .await? .is_some() { return Err(AppError::Conflict( "Bu dönemin birikimi zaten eklendi".into(), )); } let cash = cash_asset(db, household_id).await?; let mut d = new_meta(household_id); let id = d.get("_id").cloned().unwrap_or(Bson::Null); d.insert("asset_id", cash.id); d.insert("type", TxType::Buy.as_str()); d.insert("date", last_day(&p.end_date)?); d.insert("quantity_e6", amount * 10_000); d.insert("unit_price_e6", ONE_E6); d.insert("total", amount); d.insert("note", format!("{} kapanışı", p.name)); d.insert("source_period_id", period_id); d.insert("created_by", user_id); db.collection::(col::ASSET_TRANSACTIONS) .insert_one(d) .await?; txs(db) .find_one(doc! { "_id": id }) .await? .ok_or_else(|| AppError::Internal("eklenen işlem okunamadı".into())) } /// Kapalı, birikime aktarılan tutarı olan ama henüz eklenmemiş dönemler (en yeni başta) pub async fn pending_period_savings( db: &Database, household_id: Uuid, ) -> AppResult> { let linked: Vec = { let mut f = scope(household_id); f.insert("source_period_id", doc! { "$ne": null }); let mut c = txs(db).find(f).await?; let mut v = Vec::new(); while c.advance().await? { if let Some(p) = c.deserialize_current()?.source_period_id { v.push(p); } } v }; Ok(crate::services::period::list(db, household_id) .await? .into_iter() .filter(|p| { p.status == crate::models::period::PeriodStatus::Closed && p.to_savings.unwrap_or(0) > 0 && !linked.contains(&p.id) }) .collect()) } /// Yeniden kapatmada bağlı kayda yapılacak iş (transaction'dan önce hazırlanıp denetlenir) pub enum SavingsSync { Update { id: Uuid, amount: i64, date: String }, Delete { id: Uuid }, } /// Bağlı kayıt varsa yeni tutar/tarihe göre işi hazırlar. Tutar 0 → silinir. /// TL'de sonraki satışlar eldeki miktarı aşacaksa reddedilir (kapatma da yapılmaz). pub async fn plan_savings_sync( db: &Database, household_id: Uuid, period_id: Uuid, amount: i64, end_date: &str, ) -> AppResult> { let Some(linked) = period_savings_tx(db, household_id, period_id).await? else { return Ok(None); }; let date = last_day(end_date)?; let mut list: Vec = all_txs(db, household_id, Some(linked.asset_id)) .await? .into_iter() .filter(|x| x.id != linked.id) .collect(); if amount > 0 { let mut changed = linked.clone(); changed.date = date.clone(); changed.quantity_e6 = amount * 10_000; changed.total = amount; let pos = list.partition_point(|x| { (x.date.as_str(), x.id.bytes()) <= (date.as_str(), linked.id.bytes()) }); list.insert(pos, changed); } if holding::holding(&moves(&list)).is_err() { return Err(AppError::Conflict( "Birikimden yapılan TL çıkışları yeni tutarı aşıyor; önce Birikimler'deki kayıtları düzeltin" .into(), )); } Ok(Some(if amount > 0 { SavingsSync::Update { id: linked.id, amount, date, } } else { SavingsSync::Delete { id: linked.id } })) } /// Hazırlanan işi kapatma transaction'ı içinde uygular pub async fn apply_savings_sync( db: &Database, session: &mut mongodb::ClientSession, household_id: Uuid, sync: &SavingsSync, ) -> AppResult<()> { let ts = now(); let (id, set) = match sync { SavingsSync::Update { id, amount, date } => ( *id, doc! { "date": date, "quantity_e6": amount * 10_000, "unit_price_e6": ONE_E6, "total": amount, "updated_at": ts, }, ), SavingsSync::Delete { id } => (*id, doc! { "deleted_at": ts, "updated_at": ts }), }; let mut f = scope(household_id); f.insert("_id", id); db.collection::(col::ASSET_TRANSACTIONS) .update_one(f, doc! { "$set": set, "$inc": { "version": 1 } }) .session(session) .await?; Ok(()) } /// Hariç bitiş → son gün (dahil) fn last_day(end_date: &str) -> AppResult { let end = parse_date(end_date).ok_or_else(|| AppError::Internal("dönem bitişi geçersiz".into()))?; Ok(crate::services::calendar::format_date( end - time::Duration::days(1), )) }