diff --git a/backend/api.http b/backend/api.http index 9a00428..52e020d 100644 --- a/backend/api.http +++ b/backend/api.http @@ -332,3 +332,30 @@ GET {{baseUrl}}/periods/BURAYA-DONEM-ID/daily ### Son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim GET {{baseUrl}}/stats/periods?limit=12 + +# ─────────────────────────── Birikimler ─────────────────────────── +# Miktar/fiyat × 1e6 (5 gr → 5000000), tutar kuruş. TL varlığında yalnızca total yeter. + +### Varlıklar + eldeki miktar, maliyet, ortalama maliyet, gerçekleşen kâr/zarar +GET {{baseUrl}}/assets + +### Döviz ekle +POST {{baseUrl}}/assets +Content-Type: application/json + +{ "code": "GBP", "name": "İngiliz Sterlini", "unit_label": "£" } + +### Alım (birim fiyat verilmezse tutar ÷ miktar) +POST {{baseUrl}}/assets/BURAYA-VARLIK-ID/transactions +Content-Type: application/json + +{ "type": "buy", "date": "2026-11-13", "quantity_e6": 5000000, "total": 3130000, "note": "Kuyumcu" } + +### İşlem geçmişi (en yeni başta) +GET {{baseUrl}}/assets/BURAYA-VARLIK-ID/transactions + +### İşlemi düzenle / sil +PATCH {{baseUrl}}/asset-transactions/BURAYA-ISLEM-ID +Content-Type: application/json + +{ "total": 3150000, "version": 1 } diff --git a/backend/src/db/indexes.rs b/backend/src/db/indexes.rs index d788ee0..36860be 100644 --- a/backend/src/db/indexes.rs +++ b/backend/src/db/indexes.rs @@ -75,6 +75,10 @@ fn definitions() -> Vec<(&'static str, Vec)> { col::CARRYOVERS, vec![idx(doc! { "household_id": 1, "to_period_id": 1 })], ), + ( + col::ASSETS, + vec![idx(doc! { "household_id": 1, "sort_order": 1 })], + ), ( col::ASSET_TRANSACTIONS, vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })], diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index a7d2492..0639494 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -24,11 +24,18 @@ struct Migration { } /// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar. -const MIGRATIONS: &[Migration] = &[Migration { - version: 1, - name: "initial", - run: |db| Box::pin(m001_initial(db)), -}]; +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "initial", + run: |db| Box::pin(m001_initial(db)), + }, + Migration { + version: 2, + name: "default_assets", + run: |db| Box::pin(m002_default_assets(db)), + }, +]; /// Uygulanmamış migration'ları sırayla çalıştırır. pub async fn run(db: &Database) -> Result<()> { @@ -66,6 +73,36 @@ async fn m001_initial(db: &Database) -> Result<()> { Ok(()) } +/// v2: Faz 8'den önce açılmış hanelere varsayılan varlıklar (TL, altınlar, USD, EUR). +/// Varlığı olan haneye dokunmaz → tekrar çalışsa da çift kayıt olmaz. +async fn m002_default_assets(db: &Database) -> Result<()> { + #[derive(serde::Deserialize)] + struct Household { + #[serde(rename = "_id")] + id: mongodb::bson::Uuid, + } + let assets = db.collection::(col::ASSETS); + let mut cursor = db + .collection::(col::HOUSEHOLDS) + .find(doc! { "deleted_at": null }) + .projection(doc! { "_id": 1 }) + .await?; + while cursor.advance().await? { + let hid = cursor.deserialize_current()?.id; + if assets + .find_one(doc! { "household_id": hid }) + .await? + .is_none() + { + assets + .insert_many(crate::services::seed::assets(hid)) + .await?; + tracing::info!("Varsayılan varlıklar eklendi: hane {hid}"); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend/src/models/asset.rs b/backend/src/models/asset.rs new file mode 100644 index 0000000..e05f962 --- /dev/null +++ b/backend/src/models/asset.rs @@ -0,0 +1,74 @@ +//! `assets` ve `asset_transactions` — birikimler / varlıklar (DATA_MODEL.md §3.12, §3.13). +//! +//! Miktar ve birim fiyat **ölçekli tam sayı** (`_e6` = × 1.000.000); tutar kuruş. +//! Örnek: 5 gr altın → `quantity_e6: 5_000_000`; 3.250,00 TL/gr → `unit_price_e6: 3_250_000_000`. + +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum AssetKind { + /// Türk lirası (nakit birikim) + Cash, + Gold, + /// Döviz + Fx, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, rename = "AssetTxType")] +pub enum TxType { + Buy, + Sell, +} + +impl TxType { + pub fn as_str(self) -> &'static str { + match self { + TxType::Buy => "buy", + TxType::Sell => "sell", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Asset { + #[serde(rename = "_id")] + pub id: Uuid, + pub kind: AssetKind, + /// `TRY`, `XAU_GRAM`, `USD`… (hanede tekil) + pub code: String, + pub name: String, + /// `₺`, `gr`, `adet`, `$`… + pub unit_label: String, + pub sort_order: i32, + pub is_active: bool, + pub version: i32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AssetTx { + #[serde(rename = "_id")] + pub id: Uuid, + pub asset_id: Uuid, + #[serde(rename = "type")] + pub tx_type: TxType, + /// `"YYYY-MM-DD"` + pub date: String, + pub quantity_e6: i64, + /// TL × 1e6, birim başına + pub unit_price_e6: i64, + /// Kuruş — girilen gerçek tutar (komisyon farkı olabilir); maliyet bundan hesaplanır + pub total: i64, + #[serde(default)] + pub note: Option, + /// Dönem kapanışından geldiyse + #[serde(default)] + pub source_period_id: Option, + pub created_by: Uuid, + pub version: i32, +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 0de6cf3..98effb2 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,6 +1,7 @@ //! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir. //! Şema: `docs/DATA_MODEL.md` +pub mod asset; pub mod common; pub mod entry; pub mod group; diff --git a/backend/src/routes/asset.rs b/backend/src/routes/asset.rs new file mode 100644 index 0000000..92257aa --- /dev/null +++ b/backend/src/routes/asset.rs @@ -0,0 +1,294 @@ +//! Birikim / varlık uç noktaları (hanenin tüm üyeleri): +//! +//! | Yöntem | Yol | İş | +//! |---|---|---| +//! | GET | `/api/assets` | varlıklar + eldeki miktar, maliyet, ortalama maliyet, gerçekleşen kâr/zarar | +//! | POST | `/api/assets` | döviz ekle (ör. GBP) | +//! | PATCH | `/api/assets/{id}` | ad / aktiflik | +//! | GET | `/api/assets/{id}/transactions` | alım/satım geçmişi (en yeni başta) | +//! | 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) | + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use validator::Validate; + +use crate::{ + auth::AuthUser, + error::{AppError, AppResult}, + extract::ValidJson, + models::asset::{Asset, AssetKind, AssetTx, TxType}, + services::{ + asset::{self, NewTx, TxChanges}, + holding::Holding, + }, + state::AppState, +}; + +#[derive(Serialize, TS)] +#[ts(export, rename = "Asset")] +pub struct AssetDto { + id: String, + kind: AssetKind, + code: String, + name: String, + unit_label: String, + sort_order: i32, + is_active: bool, + version: i32, + /// Eldeki miktar (× 1e6) + #[ts(type = "number")] + quantity_e6: i64, + /// Eldeki miktarın maliyeti (kuruş) + #[ts(type = "number")] + cost: i64, + /// Birim başına ortalama maliyet (TL × 1e6); elde yoksa `null` + #[ts(type = "number | null")] + avg_cost_e6: Option, + /// Satışlardan gerçekleşen kâr/zarar (kuruş) + #[ts(type = "number")] + realized_pl: i64, + tx_count: u32, +} + +impl AssetDto { + fn new(a: Asset, h: Holding, tx_count: usize) -> Self { + Self { + id: a.id.to_string(), + kind: a.kind, + code: a.code, + name: a.name, + unit_label: a.unit_label, + sort_order: a.sort_order, + is_active: a.is_active, + version: a.version, + quantity_e6: h.quantity_e6, + cost: h.cost, + avg_cost_e6: h.avg_cost_e6, + realized_pl: h.realized_pl, + tx_count: tx_count as u32, + } + } +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "AssetTransaction")] +pub struct TxDto { + id: String, + asset_id: String, + #[serde(rename = "type")] + #[ts(rename = "type")] + tx_type: TxType, + date: String, + #[ts(type = "number")] + quantity_e6: i64, + /// TL × 1e6 + #[ts(type = "number")] + unit_price_e6: i64, + /// Kuruş + #[ts(type = "number")] + total: i64, + note: Option, + /// Dönem kapanışından geldiyse + source_period_id: Option, + version: i32, +} + +impl From for TxDto { + fn from(t: AssetTx) -> Self { + Self { + id: t.id.to_string(), + asset_id: t.asset_id.to_string(), + tx_type: t.tx_type, + date: t.date, + quantity_e6: t.quantity_e6, + unit_price_e6: t.unit_price_e6, + total: t.total, + note: t.note, + source_period_id: t.source_period_id.map(|u| u.to_string()), + version: t.version, + } + } +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "AssetCreateRequest", optional_fields)] +pub struct CreateRequest { + /// 3 harfli döviz kodu (ör. `GBP`) + code: String, + name: String, + /// Boşsa kod + unit_label: Option, +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "AssetUpdateRequest", optional_fields)] +pub struct UpdateRequest { + name: Option, + is_active: Option, + version: Option, +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "AssetTxCreateRequest", optional_fields)] +pub struct TxCreateRequest { + #[serde(rename = "type")] + #[ts(rename = "type")] + tx_type: TxType, + date: String, + /// × 1e6; TL varlığında yok sayılır + #[ts(type = "number", optional)] + quantity_e6: Option, + /// TL × 1e6; verilmezse tutar ÷ miktar + #[ts(type = "number", optional)] + unit_price_e6: Option, + /// Kuruş — ödenen / alınan gerçek tutar + #[ts(type = "number")] + total: i64, + note: Option, +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "AssetTxUpdateRequest", optional_fields)] +pub struct TxUpdateRequest { + date: Option, + #[ts(type = "number", optional)] + quantity_e6: Option, + #[ts(type = "number", optional)] + unit_price_e6: Option, + #[ts(type = "number", optional)] + total: Option, + /// Boş metin → notu kaldır + note: Option, + version: Option, +} + +fn path_id(id: &str) -> AppResult { + Uuid::parse_str(id).map_err(|_| AppError::NotFound) +} + +pub async fn list(State(s): State, auth: AuthUser) -> AppResult>> { + let items = asset::list(&s.db, auth.household_id).await?; + Ok(Json( + items + .into_iter() + .map(|(a, h, n)| AssetDto::new(a, h, n)) + .collect(), + )) +} + +pub async fn create( + State(s): State, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, Json)> { + let a = asset::create( + &s.db, + auth.household_id, + &req.code, + &req.name, + req.unit_label.as_deref(), + ) + .await?; + Ok(( + StatusCode::CREATED, + Json(AssetDto::new(a, Holding::default(), 0)), + )) +} + +pub async fn update( + State(s): State, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult> { + let id = path_id(&id)?; + asset::update( + &s.db, + auth.household_id, + id, + req.name, + req.is_active, + req.version, + ) + .await?; + // Güncel durumla birlikte dön + let (a, h, n) = asset::list(&s.db, auth.household_id) + .await? + .into_iter() + .find(|(a, _, _)| a.id == id) + .ok_or(AppError::NotFound)?; + Ok(Json(AssetDto::new(a, h, n))) +} + +pub async fn list_txs( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult>> { + let items = asset::list_txs(&s.db, auth.household_id, path_id(&id)?).await?; + Ok(Json(items.into_iter().map(TxDto::from).collect())) +} + +pub async fn create_tx( + State(s): State, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, Json)> { + let t = asset::create_tx( + &s.db, + auth.household_id, + auth.user_id, + path_id(&id)?, + NewTx { + tx_type: req.tx_type, + date: req.date, + quantity_e6: req.quantity_e6.unwrap_or(0), + unit_price_e6: req.unit_price_e6, + total: req.total, + note: req.note, + }, + ) + .await?; + Ok((StatusCode::CREATED, Json(t.into()))) +} + +pub async fn update_tx( + State(s): State, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult> { + let t = asset::update_tx( + &s.db, + auth.household_id, + path_id(&id)?, + TxChanges { + date: req.date, + quantity_e6: req.quantity_e6, + unit_price_e6: req.unit_price_e6, + total: req.total, + note: req.note, + version: req.version, + }, + ) + .await?; + Ok(Json(t.into())) +} + +pub async fn delete_tx( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult { + asset::delete_tx(&s.db, auth.household_id, path_id(&id)?).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 11d2a5a..c04839f 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,5 +1,6 @@ //! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında. +mod asset; mod auth; mod budget; mod catalog; @@ -98,6 +99,16 @@ pub fn router(state: AppState) -> Router { ) .route("/entries/{id}", patch(entry::update).delete(entry::delete)) .route("/entries/{id}/restore", post(entry::restore)) + .route("/assets", get(asset::list).post(asset::create)) + .route("/assets/{id}", patch(asset::update)) + .route( + "/assets/{id}/transactions", + get(asset::list_txs).post(asset::create_tx), + ) + .route( + "/asset-transactions/{id}", + patch(asset::update_tx).delete(asset::delete_tx), + ) // Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin) .fallback(|| async { AppError::NotFound }); diff --git a/backend/src/services/account.rs b/backend/src/services/account.rs index adf4e5c..7abe79e 100644 --- a/backend/src/services/account.rs +++ b/backend/src/services/account.rs @@ -120,6 +120,10 @@ async fn create_in_tx( .insert_many(seed::payment_methods(household_id)) .session(&mut *session) .await?; + docs(col::ASSETS) + .insert_many(seed::assets(household_id)) + .session(&mut *session) + .await?; (household_id, Role::Owner) } }; diff --git a/backend/src/services/asset.rs b/backend/src/services/asset.rs new file mode 100644 index 0000000..438c7e8 --- /dev/null +++ b/backend/src/services/asset.rs @@ -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, + 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()) +} diff --git a/backend/src/services/holding.rs b/backend/src/services/holding.rs new file mode 100644 index 0000000..1f28812 --- /dev/null +++ b/backend/src/services/holding.rs @@ -0,0 +1,164 @@ +//! Bir varlığın eldeki durumu: işlemlerden **ağırlıklı ortalama maliyet** yöntemiyle hesaplanır +//! (saklanmaz). Saf fonksiyon; veritabanı yok. +//! +//! - Alım: miktar += q, maliyet += toplam tutar. +//! - Satış: çıkan maliyet = maliyet × q / miktar (ortalama maliyetten); +//! gerçekleşen kâr/zarar += satış tutarı − çıkan maliyet. +//! - Hiçbir anda miktar eksiye düşemez (eldekinden fazla satılamaz). + +use crate::models::asset::TxType; + +/// Hesap için işlem özeti; **tarih sırasıyla** verilmeli (aynı gün: ekleniş sırası). +#[derive(Debug, Clone, Copy)] +pub struct Move { + pub tx_type: TxType, + pub quantity_e6: i64, + /// Kuruş + pub total: i64, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Holding { + pub quantity_e6: i64, + /// Eldeki miktarın maliyeti (kuruş) + pub cost: i64, + /// Birim başına ortalama maliyet (TL × 1e6); elde yoksa `None` + pub avg_cost_e6: Option, + /// Satışlardan gerçekleşen kâr/zarar (kuruş) + pub realized_pl: i64, +} + +/// Miktarın eksiye düştüğü işlemin sırası (0'dan) +#[derive(Debug, PartialEq, Eq)] +pub struct Oversold(pub usize); + +/// a × b / c, en yakına yuvarlanmış (taşma olmasın diye i128) +fn mul_div(a: i64, b: i64, c: i64) -> i64 { + let (a, b, c) = (i128::from(a), i128::from(b), i128::from(c)); + let n = a * b; + let q = (2 * n + c) / (2 * c); // pozitif sayılar için yarım yukarı + q as i64 +} + +pub fn holding(moves: &[Move]) -> Result { + let mut h = Holding::default(); + for (i, m) in moves.iter().enumerate() { + match m.tx_type { + TxType::Buy => { + h.quantity_e6 += m.quantity_e6; + h.cost += m.total; + } + TxType::Sell => { + if m.quantity_e6 > h.quantity_e6 { + return Err(Oversold(i)); + } + let out = if m.quantity_e6 == h.quantity_e6 { + h.cost // tamamı satıldı: yuvarlama artığı kalmasın + } else { + mul_div(h.cost, m.quantity_e6, h.quantity_e6) + }; + h.realized_pl += m.total - out; + h.cost -= out; + h.quantity_e6 -= m.quantity_e6; + } + } + } + // kuruş → TL×1e6 için ×10.000, birim e6 → ×1e6 ⇒ maliyet × 1e10 / miktar_e6 + h.avg_cost_e6 = (h.quantity_e6 > 0).then(|| mul_div(h.cost, 10_000_000_000, h.quantity_e6)); + Ok(h) +} + +/// Birim fiyat verilmezse toplam ÷ miktar (TL × 1e6) +pub fn unit_price_e6(total: i64, quantity_e6: i64) -> i64 { + mul_div(total, 10_000_000_000, quantity_e6) +} + +#[cfg(test)] +mod tests { + use super::*; + + const fn buy(q: i64, t: i64) -> Move { + Move { + tx_type: TxType::Buy, + quantity_e6: q, + total: t, + } + } + const fn sell(q: i64, t: i64) -> Move { + Move { + tx_type: TxType::Sell, + quantity_e6: q, + total: t, + } + } + const GR: i64 = 1_000_000; + + #[test] + fn weighted_average_cost() { + // 10 gr × 3.000 TL + 10 gr × 4.000 TL → ort. 3.500 TL/gr + let h = holding(&[buy(10 * GR, 3_000_000), buy(10 * GR, 4_000_000)]).unwrap(); + assert_eq!(h.quantity_e6, 20 * GR); + assert_eq!(h.cost, 7_000_000); + assert_eq!(h.avg_cost_e6, Some(3_500_000_000)); + assert_eq!(h.realized_pl, 0); + } + + #[test] + fn sell_uses_average_cost() { + // 5 gr 4.500'den satıldı: çıkan maliyet 5 × 3.500 = 17.500 → kâr 5.000 + let h = holding(&[ + buy(10 * GR, 3_000_000), + buy(10 * GR, 4_000_000), + sell(5 * GR, 2_250_000), + ]) + .unwrap(); + assert_eq!(h.quantity_e6, 15 * GR); + assert_eq!(h.cost, 5_250_000); + assert_eq!(h.realized_pl, 500_000); + assert_eq!( + h.avg_cost_e6, + Some(3_500_000_000), + "satış ortalamayı değiştirmez" + ); + } + + #[test] + fn selling_everything_clears_cost() { + // 3 parça: 1/3 maliyetler yuvarlanır ama sonunda artık kalmaz + let h = holding(&[ + buy(3 * GR, 1_000), + sell(GR, 300), + sell(GR, 300), + sell(GR, 300), + ]) + .unwrap(); + assert_eq!( + h, + Holding { + quantity_e6: 0, + cost: 0, + avg_cost_e6: None, + realized_pl: -100 + } + ); + } + + #[test] + fn cannot_sell_more_than_held() { + assert_eq!( + holding(&[buy(GR, 100), sell(2 * GR, 300)]), + Err(Oversold(1)) + ); + assert_eq!( + holding(&[sell(GR, 100), buy(GR, 100)]), + Err(Oversold(0)), + "sıra önemli" + ); + } + + #[test] + fn unit_price_from_total() { + assert_eq!(unit_price_e6(1_625_000, 5 * GR), 3_250_000_000); // 16.250 TL / 5 gr + assert_eq!(unit_price_e6(100, 3 * GR), 333_333); // 1 TL / 3 → 0,333333 + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index af91772..3472fea 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,11 +1,13 @@ //! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar. pub mod account; +pub mod asset; pub mod budget; pub mod calendar; pub mod catalog; pub mod closing; pub mod entry; +pub mod holding; pub mod invite; pub mod money; pub mod note; diff --git a/backend/src/services/seed.rs b/backend/src/services/seed.rs index 26ab200..4a10cdd 100644 --- a/backend/src/services/seed.rs +++ b/backend/src/services/seed.rs @@ -1,4 +1,5 @@ -//! Yeni hane için örnek alt kategoriler ve ödeme türleri (DATA_MODEL.md §3.5, §3.6). +//! Yeni hane için örnek alt kategoriler, ödeme türleri ve varsayılan varlıklar +//! (DATA_MODEL.md §3.5, §3.6, §3.12). use mongodb::bson::{Document, Uuid}; @@ -29,6 +30,35 @@ const PAYMENT_METHODS: &[(&str, &str)] = &[ ("Havale/EFT", "🔁"), ]; +/// (tür, kod, ad, birim) — kurlar Faz 8 Adım 2'de bu kodlarla eşleşir +const ASSETS: &[(&str, &str, &str, &str)] = &[ + ("cash", "TRY", "Türk Lirası", "₺"), + ("gold", "XAU_GRAM", "Gram Altın", "gr"), + ("gold", "XAU_CEYREK", "Çeyrek Altın", "adet"), + ("gold", "XAU_YARIM", "Yarım Altın", "adet"), + ("gold", "XAU_TAM", "Tam Altın", "adet"), + ("gold", "XAU_ONS", "Ons Altın", "ons"), + ("fx", "USD", "ABD Doları", "$"), + ("fx", "EUR", "Euro", "€"), +]; + +pub fn assets(household_id: Uuid) -> Vec { + ASSETS + .iter() + .enumerate() + .map(|(i, (kind, code, name, unit))| { + let mut d = new_meta(household_id); + d.insert("kind", *kind); + d.insert("code", *code); + d.insert("name", *name); + d.insert("unit_label", *unit); + d.insert("sort_order", (i as i32 + 1) * 10); + d.insert("is_active", true); + d + }) + .collect() +} + pub fn categories(household_id: Uuid) -> Vec { CATEGORIES .iter() diff --git a/backend/tests/assets.rs b/backend/tests/assets.rs new file mode 100644 index 0000000..df93f39 --- /dev/null +++ b/backend/tests/assets.rs @@ -0,0 +1,339 @@ +//! Birikimler: varsayılan varlıklar, döviz ekleme, alım/satım, ortalama maliyet, +//! eldekinden fazla satış kuralı (geriye tarihli kayıt, düzenleme, silme), migration v2. + +mod common; + +use axum::http::StatusCode; +use common::{Session, TestApp}; +use mongodb::bson::{Document, Uuid, doc}; +use serde_json::{Value, json}; + +const GR: i64 = 1_000_000; + +async fn assets(app: &TestApp, s: &Session) -> Vec { + let (st, b) = app.get("/assets", s).await; + assert_eq!(st, StatusCode::OK, "{b}"); + b.as_array().unwrap().clone() +} + +async fn asset(app: &TestApp, s: &Session, code: &str) -> Value { + assets(app, s) + .await + .into_iter() + .find(|a| a["code"] == code) + .unwrap_or_else(|| panic!("{code} yok")) +} + +async fn tx(app: &TestApp, s: &Session, asset_id: &str, body: Value) -> (StatusCode, Value) { + app.post(&format!("/assets/{asset_id}/transactions"), s, body) + .await +} + +fn id(v: &Value) -> String { + v["id"].as_str().unwrap().to_string() +} + +#[tokio::test] +async fn default_assets_and_custom_fx() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let list = assets(&app, &s).await; + let codes: Vec<&str> = list.iter().map(|a| a["code"].as_str().unwrap()).collect(); + assert_eq!( + codes, + [ + "TRY", + "XAU_GRAM", + "XAU_CEYREK", + "XAU_YARIM", + "XAU_TAM", + "XAU_ONS", + "USD", + "EUR" + ] + ); + assert_eq!(list[0]["kind"], "cash"); + assert_eq!(list[1]["unit_label"], "gr"); + assert_eq!(list[1]["quantity_e6"], 0); + assert_eq!(list[1]["avg_cost_e6"], Value::Null); + + let (st, gbp) = app + .post( + "/assets", + &s, + json!({ "code": " gbp ", "name": "İngiliz Sterlini", "unit_label": "£" }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{gbp}"); + assert_eq!(gbp["code"], "GBP"); + assert_eq!(gbp["kind"], "fx"); + let (st, _) = app + .post("/assets", &s, json!({ "code": "GBP", "name": "Tekrar" })) + .await; + assert_eq!(st, StatusCode::CONFLICT); + let (st, _) = app + .post("/assets", &s, json!({ "code": "GB1", "name": "Hatalı" })) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + assert_eq!( + assets(&app, &s).await.last().unwrap()["code"], + "GBP", + "sona eklenir" + ); + + // Başka hane: görmez, işlem ekleyemez + let other = app.register("Diger", None).await; + assert_eq!(assets(&app, &other).await.len(), 8); + let (st, _) = tx( + &app, + &other, + &id(&gbp), + json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": GR, "total": 5_000 }), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + let (st, _) = app + .get(&format!("/assets/{}/transactions", id(&gbp)), &other) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + app.cleanup().await; +} + +#[tokio::test] +async fn weighted_average_cost_and_oversell() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let gram = id(&asset(&app, &s, "XAU_GRAM").await); + + // 10 gr 30.000 TL (birim fiyat hesaplanır), 10 gr 40.000 TL, 5 gr 22.500 TL'ye satış + let (st, b1) = tx( + &app, + &s, + &gram, + json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": 10 * GR, "total": 3_000_000 }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{b1}"); + assert_eq!(b1["unit_price_e6"], 3_000_000_000_i64); + let (st, b2) = tx(&app, &s, &gram, json!({ "type": "buy", "date": "2026-11-05", "quantity_e6": 10 * GR, "unit_price_e6": 3_990_000_000_i64, "total": 4_000_000, "note": " komisyon dahil " })).await; + assert_eq!(st, StatusCode::CREATED, "{b2}"); + assert_eq!( + b2["unit_price_e6"], 3_990_000_000_i64, + "girilen birim fiyat korunur" + ); + assert_eq!(b2["note"], "komisyon dahil"); + let (st, _) = tx( + &app, + &s, + &gram, + json!({ "type": "sell", "date": "2026-11-10", "quantity_e6": 5 * GR, "total": 2_250_000 }), + ) + .await; + assert_eq!(st, StatusCode::CREATED); + + let a = asset(&app, &s, "XAU_GRAM").await; + assert_eq!(a["quantity_e6"], 15 * GR); + assert_eq!( + a["cost"], 5_250_000, + "maliyet tutardan (birim fiyattan değil)" + ); + assert_eq!(a["avg_cost_e6"], 3_500_000_000_i64); + assert_eq!(a["realized_pl"], 500_000); + assert_eq!(a["tx_count"], 3); + + // Eldekinden fazla satış + let (st, b) = tx( + &app, + &s, + &gram, + json!({ "type": "sell", "date": "2026-11-12", "quantity_e6": 16 * GR, "total": 1 }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + assert_eq!( + b["error"]["message"], "Satış miktarı o tarihte eldeki miktarı aşıyor", + "{b}" + ); + // Geriye tarihli: ilk alımdan önce satış + let (st, _) = tx( + &app, + &s, + &gram, + json!({ "type": "sell", "date": "2026-10-31", "quantity_e6": GR, "total": 300_000 }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + // Aynı gün: alımdan sonra eklenen satış geçerli (15 gr elde) + let (st, _) = tx( + &app, + &s, + &gram, + json!({ "type": "sell", "date": "2026-11-01", "quantity_e6": 10 * GR, "total": 3_100_000 }), + ) + .await; + assert_eq!(st, StatusCode::CREATED); + + // Hatalı girişler + for body in [ + json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": 0, "total": 100 }), + json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": GR, "total": 0 }), + json!({ "type": "buy", "date": "2026-02-30", "quantity_e6": GR, "total": 100 }), + json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": GR, "unit_price_e6": -1, "total": 100 }), + ] { + let (st, b) = tx(&app, &s, &gram, body.clone()).await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}"); + } + + // TL: miktar = tutar, birim fiyat 1 + let try_id = id(&asset(&app, &s, "TRY").await); + let (st, t) = tx( + &app, + &s, + &try_id, + json!({ "type": "buy", "date": "2026-11-30", "quantity_e6": 999, "total": 250_050 }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{t}"); + assert_eq!(t["quantity_e6"], 2_500_500_000_i64, "2.500,50 TL"); + assert_eq!(t["unit_price_e6"], GR); + let a = asset(&app, &s, "TRY").await; + assert_eq!(a["cost"], 250_050); + assert_eq!(a["avg_cost_e6"], GR); + app.cleanup().await; +} + +#[tokio::test] +async fn edits_and_deletes_keep_history_consistent() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let usd = id(&asset(&app, &s, "USD").await); + let (_, b1) = tx( + &app, + &s, + &usd, + json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": 100 * GR, "total": 4_100_000 }), + ) + .await; + let (_, b2) = tx( + &app, + &s, + &usd, + json!({ "type": "buy", "date": "2026-11-03", "quantity_e6": 100 * GR, "total": 4_200_000 }), + ) + .await; + let (_, sell) = tx(&app, &s, &usd, json!({ "type": "sell", "date": "2026-11-05", "quantity_e6": 150 * GR, "total": 6_400_000 })).await; + + // Geçmiş: en yeni başta + let (_, list) = app.get(&format!("/assets/{usd}/transactions"), &s).await; + let dates: Vec<&str> = list + .as_array() + .unwrap() + .iter() + .map(|t| t["date"].as_str().unwrap()) + .collect(); + assert_eq!(dates, ["2026-11-05", "2026-11-03", "2026-11-01"]); + + // Alımlardan biri silinirse satış eksiye düşer → reddedilir + let (st, b) = app + .delete(&format!("/asset-transactions/{}", id(&b1)), &s) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{b}"); + // Satışı büyütmek → eksi + let (st, _) = app + .patch( + &format!("/asset-transactions/{}", id(&sell)), + &s, + json!({ "quantity_e6": 250 * GR }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + // 2. alımı satıştan sonraya taşımak → satış anında yalnızca 100 $ var + let (st, _) = app + .patch( + &format!("/asset-transactions/{}", id(&b2)), + &s, + json!({ "date": "2026-11-06" }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + // Sürüm çakışması + let (st, _) = app + .patch( + &format!("/asset-transactions/{}", id(&b2)), + &s, + json!({ "note": "x", "version": 99 }), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + + // Geçerli düzenleme: satış 100 $ → sonra 2. alım silinebilir + let (st, e) = app + .patch( + &format!("/asset-transactions/{}", id(&sell)), + &s, + json!({ "quantity_e6": 100 * GR, "total": 4_300_000, "version": 1 }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{e}"); + assert_eq!( + e["unit_price_e6"], 430_000_000, + "43.000 TL / 100 → 430 TL, birim fiyat yeniden hesaplandı" + ); + assert_eq!(e["version"], 2); + let (st, _) = app + .delete(&format!("/asset-transactions/{}", id(&b2)), &s) + .await; + assert_eq!(st, StatusCode::NO_CONTENT); + let a = asset(&app, &s, "USD").await; + assert_eq!(a["quantity_e6"], 0); + assert_eq!(a["cost"], 0); + assert_eq!(a["realized_pl"], 200_000, "43.000 − 41.000"); + assert_eq!(a["tx_count"], 2, "silinen sayılmaz"); + + // Pasif varlığa işlem eklenmez, ama listede kalır + let (st, _) = app + .patch(&format!("/assets/{usd}"), &s, json!({ "is_active": false })) + .await; + assert_eq!(st, StatusCode::OK); + let (st, _) = tx( + &app, + &s, + &usd, + json!({ "type": "buy", "date": "2026-11-07", "quantity_e6": GR, "total": 4_300 }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + assert_eq!(asset(&app, &s, "USD").await["is_active"], false); + app.cleanup().await; +} + +#[tokio::test] +async fn migration_adds_defaults_to_existing_households_once() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let hid = Uuid::parse_str(&s.household_id).unwrap(); + // Faz 8 öncesi hane: varlık yok, v2 uygulanmamış + app.db + .collection::("assets") + .delete_many(doc! { "household_id": hid }) + .await + .unwrap(); + app.db + .collection::("schema_migrations") + .delete_one(doc! { "_id": 2 }) + .await + .unwrap(); + assert!(assets(&app, &s).await.is_empty()); + + kakeibo::db::migrations::run(&app.db).await.unwrap(); + assert_eq!(assets(&app, &s).await.len(), 8); + // Tekrar çalışsa da çift kayıt yok + app.db + .collection::("schema_migrations") + .delete_one(doc! { "_id": 2 }) + .await + .unwrap(); + kakeibo::db::migrations::run(&app.db).await.unwrap(); + assert_eq!(assets(&app, &s).await.len(), 8); + app.cleanup().await; +} diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 35c28f0..5bec67a 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -160,8 +160,9 @@ Renkler ve ikonlar frontend tasarım token'larında. kind: "cash" | "gold" | "fx", code: "TRY" | "XAU_GRAM" | "XAU_CEYREK" | "XAU_YARIM" | "XAU_TAM" | "XAU_ONS" | "USD" | "EUR" | ..., name: "Gram Altın", unit_label: "gr", - is_active, created_at, ... } + sort_order, is_active, created_at, ... } ``` +- Varsayılanlar hane açılınca gelir (migration v2 eski hanelere ekler): TRY, XAU_GRAM, XAU_CEYREK, XAU_YARIM, XAU_TAM, XAU_ONS, USD, EUR. Kullanıcı yalnızca **döviz** (`fx`, 3 harfli kod) ekler; kod hanede tekil. Pasif varlık listede kalır, yeni işlem almaz. ### 3.13 `asset_transactions` — Alım / satım ```js @@ -174,7 +175,9 @@ Renkler ve ikonlar frontend tasarım token'larında. note, source_period_id /* dönem kapanışından geldiyse */, created_at, ... } ``` -- Ortalama maliyet ve kâr/zarar bu kayıtlardan hesaplanır, saklanmaz. +- 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`. ### 3.14 `exchange_rates` — Kurlar ```js diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 723bf70..41b8a0f 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,10 +1,11 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 8 – Adım 1** (plan onayı bekleniyor, `docs/PHASES.md` Faz 8): varlıklar + alım/satım backend. Kullanıcıya sorulan kararlar: kur kaynakları (TCMB + xaus.com + manuel), değerleme kuru (alış önerildi), kapanış birikimlerinin TL varlığına onaylı eklenmesi. +**Faz 8 – Adım 2:** Kurlar (backend): `services/rates.rs` — TCMB `today.xml` ayrıştırıcı (USD/EUR/… **ForexBuying** = değerleme; quick-xml ya da basit ayrıştırma, gerekçe ver) + xaus.com `/api/v1/spot` (`per_gram_usd` × USD → XAU_GRAM; çeyrek/yarım/tam has gram katsayısı, ons = 31,1035 gr) → `exchange_rates` (`household_id: null`, `rate_e6`, `source`, `rate_date`, `fetched_at`); saatlik tokio arka plan görevi (zaman aşımı, hata → log, son kur kalır); `reqwest` gerekecek (gerekçe). Manuel kur `POST /rates` (hane). `GET /portfolio`: varlık başına güncel kur (manuel en yeni > otomatik en yeni), değer, kâr/zarar tutar/%, toplamlar, dağılım, kur zamanı. Testlerde ağ yok: ayrıştırıcılar örnek dosyayla, servis veritabanına elle yazılmış kurla. **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 +- **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ı. - **Kategoriler (kullanıcı, 2026-11-12):** 3. kat alt kategori **yok** (grup → kategori). Şartı: raporlarda **tüm** kategorilerin dağılımı görünmeli (yalnızca ilk 5 değil). Amaç "market ne için?" gibi kırılım → kategoriyi bölerek (ör. Market · Gıda / Temizlik); bunun için Ayarlar'da kategori yönetimi ekranı gerekli. @@ -122,3 +123,6 @@ - **Adım 1:** `services/stats.rs` + `routes/stats.rs`: `GET /periods/{id}/daily` (`DaySpent[]`: dönemin her günü, `spent` = `GroupAmounts`, harcama yoksa 0; `budget::spent_by_group_and_date` artık `pub(crate)`), `GET /stats/periods?limit=12` (`PeriodStats`: `total_saved` tüm dönemlerin `to_savings` toplamı — limitten bağımsız, yeniden açılan dönemin eski tutarı sayılır; `periods` eskiden yeniye, her biri `budget::summary` ile → dönem başına ~5 sorgu, en fazla 24). `tests/stats.rs` (2). Mutasyon 2/2 yakalandı. Backend 118 test. - **Adım 2:** Panel (`features/dashboard/`): `page.tsx` (açık dönem; yoksa `NoPeriodCard` + son dönem salt okunur, `lib/dashboard.ts` → `dashboardPeriod`), `overview.tsx` (kartlar: gelir, sabit, toplam birikim `useStats().total_saved`, borç yalnızca varsa — 3 kartta ızgara 3 sütun; **Anlık durum** DOM'da önce/mobilde üstte, masaüstünde sağda: kalan harcanabilir + `dailyAllowance` = kalan ÷ kalan gün (bugün dahil, kuruş aşağı); gruplar `WeekSummary` artık `index: number | 'month'`, Haftalık/Aylık geçişi; boş durumlar: bütçe dağıtılmamış → Plan, harcama yok → Defter). "Harcama Ekle": masaüstünde başlıkta, mobilde yüzen (alt menünün üstü) → `/defter` `state.focus` → `EntryForm autoFocus`. `components/skeleton.tsx`. `queryKeys.stats = ['periods','stats']` (kapatma/plan ile yenilenir), kayıt mutasyonları da stats'ı yeniler. Frontend 95 test; e2e 22 kontrol (DB silindi). Canlı yayın 2026-11-12 güncellendi (kullanıcı Deploy yaptı; `/api/stats/periods` 401). - **Adım 3:** Grafikler (`features/dashboard/charts.tsx`, Recharts 3, **lazy** ayrı parça ~110 KB gz): Günlük/Haftalık/Aylık (`Segmented` — `components/segmented.tsx`, Panel grup kartı da kullanıyor). Seriler `lib/charts.ts` (`dailySeries`: harcanabilir günlere eşit + kümülatif harcanan, gelecek gün `null`; `weeklySeries`: hafta sonu planı, içinde bulunulan hafta bugüne kadar; `monthlySeries`: `useStats` dönemleri; `lastPoint`) + test. Backend: `budget::Summary.spent_by_group` → `PeriodStat.spent_by_group` (aylık grup grafiği). Grup grafiğinde harcanabilir çizgisi varsayılan kapalı (ölçeği ezip grup çizgilerini sıfıra yapıştırıyordu), `aria-pressed` düğmeyle açılır. Lejant/ipucu sırası `itemSorter` ile sabit (Recharts 3 varsayılanı alfabetik). Grafik `aria-hidden`, altında metin özeti (figcaption). `formatCompact` (eksen, bölünmez boşluk). **Ders:** `--color-*` değişkenleri `@theme inline` yüzünden derlenmiş CSS'te yok → SVG'de ham `var(--needs)` (CLAUDE.md'ye eklendi); e2e'de çizgi rengi `getComputedStyle` ile denetlenir. Tam sayfa ekran görüntüsü (`captureBeyondViewport`) ResponsiveContainer'ı küçültebilir → grafik için görünen alan görüntüsü al. Frontend 101, backend 118 test; e2e 18 kontrol. `npm audit` uyarıları shadcn CLI'dan (geliştirme aracı; `--force` shadcn'i 1.0'a düşürür → uygulanmadı). **Faz 7 ✅.** + +### 2026-11-13 — Faz 8 +- **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. diff --git a/docs/PHASES.md b/docs/PHASES.md index 5d9a039..eb7a57b 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -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 | ⏳ | @@ -72,8 +72,8 @@ - ✅ 2. Frontend: özet kartları (gelir, sabit gider, toplam birikim, devreden borç, grup planlanan/gerçekleşen haftalık–aylık, **anlık durum** en belirgin) + "➕ Harcama Ekle" (mobilde yüzen düğme) + skeleton ve boş durumlar - ✅ 3. Frontend: grafikler (Recharts) — kümülatif harcanabilir vs. gerçekleşen; 4 grup + harcanabilir; Günlük / Haftalık / Aylık sekmeleri, koyu tema, erişilebilir özet -## Faz 8 Adımları (taslak) -- ⏳ 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 +## Faz 8 Adımları +- ✅ 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 diff --git a/frontend/src/types/Asset.ts b/frontend/src/types/Asset.ts new file mode 100644 index 0000000..c2be47d --- /dev/null +++ b/frontend/src/types/Asset.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AssetKind } from "./AssetKind"; + +export type Asset = { id: string, kind: AssetKind, code: string, name: string, unit_label: string, sort_order: number, is_active: boolean, version: number, +/** + * Eldeki miktar (× 1e6) + */ +quantity_e6: number, +/** + * Eldeki miktarın maliyeti (kuruş) + */ +cost: number, +/** + * Birim başına ortalama maliyet (TL × 1e6); elde yoksa `null` + */ +avg_cost_e6: number | null, +/** + * Satışlardan gerçekleşen kâr/zarar (kuruş) + */ +realized_pl: number, tx_count: number, }; diff --git a/frontend/src/types/AssetCreateRequest.ts b/frontend/src/types/AssetCreateRequest.ts new file mode 100644 index 0000000..54483fa --- /dev/null +++ b/frontend/src/types/AssetCreateRequest.ts @@ -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 AssetCreateRequest = { +/** + * 3 harfli döviz kodu (ör. `GBP`) + */ +code: string, name: string, +/** + * Boşsa kod + */ +unit_label?: string, }; diff --git a/frontend/src/types/AssetKind.ts b/frontend/src/types/AssetKind.ts new file mode 100644 index 0000000..8e36dea --- /dev/null +++ b/frontend/src/types/AssetKind.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AssetKind = "cash" | "gold" | "fx"; diff --git a/frontend/src/types/AssetTransaction.ts b/frontend/src/types/AssetTransaction.ts new file mode 100644 index 0000000..ed31f95 --- /dev/null +++ b/frontend/src/types/AssetTransaction.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AssetTxType } from "./AssetTxType"; + +export type AssetTransaction = { id: string, asset_id: string, type: AssetTxType, date: string, quantity_e6: number, +/** + * TL × 1e6 + */ +unit_price_e6: number, +/** + * Kuruş + */ +total: number, note: string | null, +/** + * Dönem kapanışından geldiyse + */ +source_period_id: string | null, version: number, }; diff --git a/frontend/src/types/AssetTxCreateRequest.ts b/frontend/src/types/AssetTxCreateRequest.ts new file mode 100644 index 0000000..417c457 --- /dev/null +++ b/frontend/src/types/AssetTxCreateRequest.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AssetTxType } from "./AssetTxType"; + +export type AssetTxCreateRequest = { type: AssetTxType, date: string, +/** + * × 1e6; TL varlığında yok sayılır + */ +quantity_e6?: number, +/** + * TL × 1e6; verilmezse tutar ÷ miktar + */ +unit_price_e6?: number, +/** + * Kuruş — ödenen / alınan gerçek tutar + */ +total: number, note?: string, }; diff --git a/frontend/src/types/AssetTxType.ts b/frontend/src/types/AssetTxType.ts new file mode 100644 index 0000000..584a375 --- /dev/null +++ b/frontend/src/types/AssetTxType.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AssetTxType = "buy" | "sell"; diff --git a/frontend/src/types/AssetTxUpdateRequest.ts b/frontend/src/types/AssetTxUpdateRequest.ts new file mode 100644 index 0000000..81725a1 --- /dev/null +++ b/frontend/src/types/AssetTxUpdateRequest.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AssetTxUpdateRequest = { date?: string, quantity_e6?: number, unit_price_e6?: number, total?: number, +/** + * Boş metin → notu kaldır + */ +note?: string, version?: number, }; diff --git a/frontend/src/types/AssetUpdateRequest.ts b/frontend/src/types/AssetUpdateRequest.ts new file mode 100644 index 0000000..6a27370 --- /dev/null +++ b/frontend/src/types/AssetUpdateRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AssetUpdateRequest = { name?: string, is_active?: boolean, version?: number, };