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:
@@ -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 }
|
||||
|
||||
@@ -75,6 +75,10 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
|
||||
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 })],
|
||||
|
||||
@@ -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::<Document>(col::ASSETS);
|
||||
let mut cursor = db
|
||||
.collection::<Household>(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::*;
|
||||
|
||||
@@ -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<String>,
|
||||
/// Dönem kapanışından geldiyse
|
||||
#[serde(default)]
|
||||
pub source_period_id: Option<Uuid>,
|
||||
pub created_by: Uuid,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<i64>,
|
||||
/// 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<String>,
|
||||
/// Dönem kapanışından geldiyse
|
||||
source_period_id: Option<String>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
impl From<AssetTx> 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "AssetUpdateRequest", optional_fields)]
|
||||
pub struct UpdateRequest {
|
||||
name: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
/// TL × 1e6; verilmezse tutar ÷ miktar
|
||||
#[ts(type = "number", optional)]
|
||||
unit_price_e6: Option<i64>,
|
||||
/// Kuruş — ödenen / alınan gerçek tutar
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "AssetTxUpdateRequest", optional_fields)]
|
||||
pub struct TxUpdateRequest {
|
||||
date: Option<String>,
|
||||
#[ts(type = "number", optional)]
|
||||
quantity_e6: Option<i64>,
|
||||
#[ts(type = "number", optional)]
|
||||
unit_price_e6: Option<i64>,
|
||||
#[ts(type = "number", optional)]
|
||||
total: Option<i64>,
|
||||
/// Boş metin → notu kaldır
|
||||
note: Option<String>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
fn path_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn list(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<Vec<AssetDto>>> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<AssetDto>)> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<AssetDto>> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Vec<TxDto>>> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<TxCreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<TxDto>)> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<TxUpdateRequest>,
|
||||
) -> AppResult<Json<TxDto>> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
asset::delete_tx(&s.db, auth.household_id, path_id(&id)?).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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<i64>,
|
||||
/// 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<Holding, Oversold> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Document> {
|
||||
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<Document> {
|
||||
CATEGORIES
|
||||
.iter()
|
||||
|
||||
@@ -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<Value> {
|
||||
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::<Document>("assets")
|
||||
.delete_many(doc! { "household_id": hid })
|
||||
.await
|
||||
.unwrap();
|
||||
app.db
|
||||
.collection::<Document>("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::<Document>("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;
|
||||
}
|
||||
Reference in New Issue
Block a user