feat(birikim): varlıklar ve alım/satım kayıtları (ortalama maliyet)

- Varsayılan varlıklar (TL, altınlar, USD, EUR) + migration v2 ile eski hanelere ekleme
- Döviz ekleme, varlık adı/aktifliği
- Alım/satım ekle, düzenle, sil; eldekinden fazla satış (geriye tarihli dahil) engellenir
- Ağırlıklı ortalama maliyet ve gerçekleşen kâr/zarar (hesaplanır, saklanmaz)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 00:35:23 +03:00
parent 1ef28b80a3
commit 267d292a90
24 changed files with 1516 additions and 12 deletions
+431
View File
@@ -0,0 +1,431 @@
//! 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<i64>,
pub total: i64,
pub note: Option<String>,
}
#[derive(Default)]
pub struct TxChanges {
pub date: Option<String>,
pub quantity_e6: Option<i64>,
pub unit_price_e6: Option<i64>,
pub total: Option<i64>,
/// `Some("")` → notu kaldır
pub note: Option<String>,
pub version: Option<i32>,
}
fn assets(db: &Database) -> mongodb::Collection<Asset> {
db.collection(col::ASSETS)
}
fn txs(db: &Database) -> mongodb::Collection<AssetTx> {
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<Vec<(Asset, Holding, usize)>> {
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<Uuid, Vec<AssetTx>> = 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<Asset> {
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<Asset> {
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::<Document>(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<String>,
is_active: Option<bool>,
version: Option<i32>,
) -> AppResult<Asset> {
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<Vec<AssetTx>> {
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<AssetTx> {
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::<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()))
}
pub async fn update_tx(
db: &Database,
household_id: Uuid,
id: Uuid,
ch: TxChanges,
) -> AppResult<AssetTx> {
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<AssetTx> = 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<AssetTx> = 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<AssetTx> {
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<Uuid>,
) -> AppResult<Vec<AssetTx>> {
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<Move> {
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<i64>,
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<String> {
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<String> {
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<Option<String>> {
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())
}