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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 00:35:23 +03:00
parent 1ef28b80a3
commit 267d292a90
24 changed files with 1516 additions and 12 deletions
+27
View File
@@ -332,3 +332,30 @@ GET {{baseUrl}}/periods/BURAYA-DONEM-ID/daily
### Son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim ### Son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim
GET {{baseUrl}}/stats/periods?limit=12 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 }
+4
View File
@@ -75,6 +75,10 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
col::CARRYOVERS, col::CARRYOVERS,
vec![idx(doc! { "household_id": 1, "to_period_id": 1 })], vec![idx(doc! { "household_id": 1, "to_period_id": 1 })],
), ),
(
col::ASSETS,
vec![idx(doc! { "household_id": 1, "sort_order": 1 })],
),
( (
col::ASSET_TRANSACTIONS, col::ASSET_TRANSACTIONS,
vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })], vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })],
+42 -5
View File
@@ -24,11 +24,18 @@ struct Migration {
} }
/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar. /// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar.
const MIGRATIONS: &[Migration] = &[Migration { const MIGRATIONS: &[Migration] = &[
version: 1, Migration {
name: "initial", version: 1,
run: |db| Box::pin(m001_initial(db)), 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. /// Uygulanmamış migration'ları sırayla çalıştırır.
pub async fn run(db: &Database) -> Result<()> { pub async fn run(db: &Database) -> Result<()> {
@@ -66,6 +73,36 @@ async fn m001_initial(db: &Database) -> Result<()> {
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+74
View File
@@ -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
View File
@@ -1,6 +1,7 @@
//! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir. //! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir.
//! Şema: `docs/DATA_MODEL.md` //! Şema: `docs/DATA_MODEL.md`
pub mod asset;
pub mod common; pub mod common;
pub mod entry; pub mod entry;
pub mod group; pub mod group;
+294
View File
@@ -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)
}
+11
View File
@@ -1,5 +1,6 @@
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında. //! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
mod asset;
mod auth; mod auth;
mod budget; mod budget;
mod catalog; mod catalog;
@@ -98,6 +99,16 @@ pub fn router(state: AppState) -> Router {
) )
.route("/entries/{id}", patch(entry::update).delete(entry::delete)) .route("/entries/{id}", patch(entry::update).delete(entry::delete))
.route("/entries/{id}/restore", post(entry::restore)) .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) // Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
.fallback(|| async { AppError::NotFound }); .fallback(|| async { AppError::NotFound });
+4
View File
@@ -120,6 +120,10 @@ async fn create_in_tx(
.insert_many(seed::payment_methods(household_id)) .insert_many(seed::payment_methods(household_id))
.session(&mut *session) .session(&mut *session)
.await?; .await?;
docs(col::ASSETS)
.insert_many(seed::assets(household_id))
.session(&mut *session)
.await?;
(household_id, Role::Owner) (household_id, Role::Owner)
} }
}; };
+431
View File
@@ -0,0 +1,431 @@
//! Birikimler / varlıklar ve alım-satım kayıtları (KAKEIBO_PROMPT §8.7, DATA_MODEL.md §3.12–3.13).
//!
//! Kurallar:
//! - Varsayılan varlıklar hane açılınca gelir (TL, altınlar, USD, EUR); kullanıcı **döviz** ekleyebilir.
//! - Miktar/fiyat ölçekli tam sayı (`_e6`), tutar kuruş. TL varlığında miktar = tutar, birim fiyat 1.
//! - Eldeki miktar, maliyet ve kâr/zarar saklanmaz; işlemlerden hesaplanır (`holding`).
//! - Ekleme, düzenleme ve silmede tüm işlemler **tarih sırasıyla** yeniden denetlenir:
//! hiçbir tarihte eldekinden fazla satılmış olamaz (geriye tarihli kayıt / alım silme dahil).
//! - Silme soft delete. Hanenin tüm üyeleri yazabilir.
use std::collections::HashMap;
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
asset::{Asset, AssetKind, AssetTx, TxType},
common::{new_meta, now, scope},
},
services::{
calendar::parse_date,
holding::{self, Holding, Move, Oversold},
period::MAX_AMOUNT,
},
};
/// Not üst sınırı (karakter)
pub const MAX_NOTE: usize = 200;
/// Miktar ve birim fiyat üst sınırı (e6): 1 milyar birim / 1 milyar TL
const MAX_E6: i64 = 1_000_000_000_000_000;
/// TL varlığında birim fiyat (1 TL)
const ONE_E6: i64 = 1_000_000;
pub struct NewTx {
pub tx_type: TxType,
pub date: String,
/// TL varlığında yok sayılır (tutardan türetilir)
pub quantity_e6: i64,
/// Verilmezse tutar ÷ miktar
pub unit_price_e6: Option<i64>,
pub total: i64,
pub note: Option<String>,
}
#[derive(Default)]
pub struct TxChanges {
pub date: Option<String>,
pub quantity_e6: Option<i64>,
pub unit_price_e6: Option<i64>,
pub total: Option<i64>,
/// `Some("")` → notu kaldır
pub note: Option<String>,
pub version: Option<i32>,
}
fn assets(db: &Database) -> mongodb::Collection<Asset> {
db.collection(col::ASSETS)
}
fn txs(db: &Database) -> mongodb::Collection<AssetTx> {
db.collection(col::ASSET_TRANSACTIONS)
}
// ---------- Varlıklar ----------
/// Hanenin varlıkları (sıralı) ve her birinin hesaplanmış durumu + işlem sayısı
pub async fn list(db: &Database, household_id: Uuid) -> AppResult<Vec<(Asset, Holding, usize)>> {
let mut cursor = assets(db)
.find(scope(household_id))
.sort(doc! { "sort_order": 1, "_id": 1 })
.await?;
let mut list = Vec::new();
while cursor.advance().await? {
list.push(cursor.deserialize_current()?);
}
let mut by_asset: HashMap<Uuid, Vec<AssetTx>> = HashMap::new();
for t in all_txs(db, household_id, None).await? {
by_asset.entry(t.asset_id).or_default().push(t);
}
list.into_iter()
.map(|a: Asset| {
let ts = by_asset.remove(&a.id).unwrap_or_default();
let h = holding::holding(&moves(&ts))
.map_err(|_| AppError::Internal(format!("varlık {} eksi bakiyede", a.id)))?;
Ok((a, h, ts.len()))
})
.collect()
}
pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Asset> {
let mut f = scope(household_id);
f.insert("_id", id);
assets(db).find_one(f).await?.ok_or(AppError::NotFound)
}
/// Kullanıcının eklediği döviz (ör. GBP). Kod hanede tekil.
pub async fn create(
db: &Database,
household_id: Uuid,
code: &str,
name: &str,
unit_label: Option<&str>,
) -> AppResult<Asset> {
let code = code.trim().to_uppercase();
if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
return Err(AppError::BadRequest(
"Döviz kodu 3 harf olmalı (ör. GBP)".into(),
));
}
let name = clean_name(name)?;
let mut f = scope(household_id);
f.insert("code", &code);
if assets(db).find_one(f).await?.is_some() {
return Err(AppError::Conflict("Bu varlık zaten var".into()));
}
let last = assets(db)
.find_one(scope(household_id))
.sort(doc! { "sort_order": -1 })
.await?
.map_or(0, |a| a.sort_order);
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("kind", "fx");
d.insert("code", &code);
d.insert("name", name);
d.insert(
"unit_label",
unit_label
.map(str::trim)
.filter(|u| !u.is_empty())
.unwrap_or(&code),
);
d.insert("sort_order", last + 10);
d.insert("is_active", true);
db.collection::<Document>(col::ASSETS).insert_one(d).await?;
assets(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen varlık okunamadı".into()))
}
/// Ad ve aktiflik. Pasif varlık listede kalır (geçmiş işlemler) ama yeni işlem alınmaz.
pub async fn update(
db: &Database,
household_id: Uuid,
id: Uuid,
name: Option<String>,
is_active: Option<bool>,
version: Option<i32>,
) -> AppResult<Asset> {
let current = get(db, household_id, id).await?;
if version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
if let Some(n) = name {
set.insert("name", clean_name(&n)?);
}
if let Some(a) = is_active {
set.insert("is_active", a);
}
let mut f = scope(household_id);
f.insert("_id", id);
f.insert("version", current.version);
assets(db)
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
.return_document(ReturnDocument::After)
.await?
.ok_or_else(stale)
}
// ---------- İşlemler ----------
/// Varlığın işlemleri, en yeni başta
pub async fn list_txs(
db: &Database,
household_id: Uuid,
asset_id: Uuid,
) -> AppResult<Vec<AssetTx>> {
get(db, household_id, asset_id).await?;
let mut list = all_txs(db, household_id, Some(asset_id)).await?;
list.reverse();
Ok(list)
}
pub async fn create_tx(
db: &Database,
household_id: Uuid,
user_id: Uuid,
asset_id: Uuid,
t: NewTx,
) -> AppResult<AssetTx> {
let asset = get(db, household_id, asset_id).await?;
if !asset.is_active {
return Err(AppError::BadRequest(
"Bu varlık pasif; önce etkinleştirin".into(),
));
}
let date = clean_date(&t.date)?;
let (quantity_e6, unit_price_e6, total) =
clean_amounts(&asset, t.quantity_e6, t.unit_price_e6, t.total)?;
let note = clean_note(t.note.as_deref())?;
// Yeni kayıt dahil sıralı denetim (yeni kayıt aynı günün sonuna eklenir)
let list = all_txs(db, household_id, Some(asset_id)).await?;
let pos = list.partition_point(|x| x.date <= date);
let mut ms = moves(&list);
ms.insert(
pos,
Move {
tx_type: t.tx_type,
quantity_e6,
total,
},
);
check(&ms)?;
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("asset_id", asset_id);
d.insert("type", t.tx_type.as_str());
d.insert("date", date);
d.insert("quantity_e6", quantity_e6);
d.insert("unit_price_e6", unit_price_e6);
d.insert("total", total);
d.insert("note", note.map_or(Bson::Null, Bson::String));
d.insert("source_period_id", Bson::Null);
d.insert("created_by", user_id);
db.collection::<Document>(col::ASSET_TRANSACTIONS)
.insert_one(d)
.await?;
txs(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen işlem okunamadı".into()))
}
pub async fn update_tx(
db: &Database,
household_id: Uuid,
id: Uuid,
ch: TxChanges,
) -> AppResult<AssetTx> {
let current = get_tx(db, household_id, id).await?;
if ch.version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let asset = get(db, household_id, current.asset_id).await?;
let date = match ch.date {
Some(d) => clean_date(&d)?,
None => current.date.clone(),
};
// Miktar ya da tutar değiştiyse birim fiyat (verilmediyse) yeniden hesaplanır
let amounts_changed = ch.quantity_e6.is_some() || ch.total.is_some();
let (quantity_e6, unit_price_e6, total) = clean_amounts(
&asset,
ch.quantity_e6.unwrap_or(current.quantity_e6),
ch.unit_price_e6
.or((!amounts_changed).then_some(current.unit_price_e6)),
ch.total.unwrap_or(current.total),
)?;
// Değişmiş haliyle sıralı denetim
let mut list: Vec<AssetTx> = all_txs(db, household_id, Some(current.asset_id))
.await?
.into_iter()
.filter(|x| x.id != id)
.collect();
let mut changed = current.clone();
changed.date = date.clone();
changed.quantity_e6 = quantity_e6;
changed.total = total;
// MongoDB `_id` (UUID) sırası bayt sırasıdır
let pos =
list.partition_point(|x| (x.date.as_str(), x.id.bytes()) <= (date.as_str(), id.bytes()));
list.insert(pos, changed);
check(&moves(&list))?;
let mut set = doc! {
"updated_at": now(),
"date": date,
"quantity_e6": quantity_e6,
"unit_price_e6": unit_price_e6,
"total": total,
};
if let Some(n) = ch.note {
set.insert(
"note",
clean_note(Some(&n))?.map_or(Bson::Null, Bson::String),
);
}
let mut f = scope(household_id);
f.insert("_id", id);
f.insert("version", current.version);
txs(db)
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
.return_document(ReturnDocument::After)
.await?
.ok_or_else(stale)
}
/// Soft delete. Bir alımı silmek sonraki satışları eksiye düşürecekse reddedilir.
pub async fn delete_tx(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
let current = get_tx(db, household_id, id).await?;
let rest: Vec<AssetTx> = all_txs(db, household_id, Some(current.asset_id))
.await?
.into_iter()
.filter(|x| x.id != id)
.collect();
if holding::holding(&moves(&rest)).is_err() {
return Err(AppError::Conflict(
"Bu alım silinirse sonraki satışlar eldeki miktarı aşar; önce satışı düzeltin".into(),
));
}
let ts = now();
let mut f = scope(household_id);
f.insert("_id", id);
txs(db)
.update_one(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
// ---------- yardımcılar ----------
async fn get_tx(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<AssetTx> {
let mut f = scope(household_id);
f.insert("_id", id);
txs(db).find_one(f).await?.ok_or(AppError::NotFound)
}
/// Hanenin (ya da bir varlığın) işlemleri, **tarih sırasıyla** (aynı gün: ekleniş sırası;
/// UUID v7 zamana göre sıralı)
async fn all_txs(
db: &Database,
household_id: Uuid,
asset_id: Option<Uuid>,
) -> AppResult<Vec<AssetTx>> {
let mut f = scope(household_id);
if let Some(a) = asset_id {
f.insert("asset_id", a);
}
let mut cursor = txs(db).find(f).sort(doc! { "date": 1, "_id": 1 }).await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
fn moves(list: &[AssetTx]) -> Vec<Move> {
list.iter()
.map(|t| Move {
tx_type: t.tx_type,
quantity_e6: t.quantity_e6,
total: t.total,
})
.collect()
}
fn check(ms: &[Move]) -> AppResult<()> {
holding::holding(ms).map(|_| ()).map_err(|Oversold(_)| {
AppError::BadRequest("Satış miktarı o tarihte eldeki miktarı aşıyor".into())
})
}
/// (miktar, birim fiyat, tutar). TL varlığında miktar = tutar, fiyat 1.
fn clean_amounts(
asset: &Asset,
quantity_e6: i64,
unit_price_e6: Option<i64>,
total: i64,
) -> AppResult<(i64, i64, i64)> {
if total <= 0 || total > MAX_AMOUNT {
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
}
if asset.kind == AssetKind::Cash {
// kuruş → TL × 1e6
return Ok((total * 10_000, ONE_E6, total));
}
if quantity_e6 <= 0 || quantity_e6 > MAX_E6 {
return Err(AppError::BadRequest("Miktar sıfırdan büyük olmalı".into()));
}
let price = match unit_price_e6 {
Some(p) if p <= 0 || p > MAX_E6 => {
return Err(AppError::BadRequest("Birim fiyat geçersiz".into()));
}
Some(p) => p,
None => holding::unit_price_e6(total, quantity_e6),
};
Ok((quantity_e6, price, total))
}
fn clean_date(s: &str) -> AppResult<String> {
let s = s.trim();
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
Ok(s.to_string())
}
fn clean_name(s: &str) -> AppResult<String> {
let s = s.trim();
if s.is_empty() || s.chars().count() > 60 {
return Err(AppError::BadRequest("Ad 1–60 karakter olmalı".into()));
}
Ok(s.to_string())
}
fn clean_note(n: Option<&str>) -> AppResult<Option<String>> {
match n.map(str::trim) {
None | Some("") => Ok(None),
Some(s) if s.chars().count() > MAX_NOTE => Err(AppError::BadRequest(format!(
"Not en fazla {MAX_NOTE} karakter olabilir"
))),
Some(s) => Ok(Some(s.to_string())),
}
}
fn stale() -> AppError {
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
+164
View File
@@ -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
}
}
+2
View File
@@ -1,11 +1,13 @@
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar. //! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
pub mod account; pub mod account;
pub mod asset;
pub mod budget; pub mod budget;
pub mod calendar; pub mod calendar;
pub mod catalog; pub mod catalog;
pub mod closing; pub mod closing;
pub mod entry; pub mod entry;
pub mod holding;
pub mod invite; pub mod invite;
pub mod money; pub mod money;
pub mod note; pub mod note;
+31 -1
View File
@@ -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}; use mongodb::bson::{Document, Uuid};
@@ -29,6 +30,35 @@ const PAYMENT_METHODS: &[(&str, &str)] = &[
("Havale/EFT", "🔁"), ("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> { pub fn categories(household_id: Uuid) -> Vec<Document> {
CATEGORIES CATEGORIES
.iter() .iter()
+339
View File
@@ -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;
}
+5 -2
View File
@@ -160,8 +160,9 @@ Renkler ve ikonlar frontend tasarım token'larında.
kind: "cash" | "gold" | "fx", kind: "cash" | "gold" | "fx",
code: "TRY" | "XAU_GRAM" | "XAU_CEYREK" | "XAU_YARIM" | "XAU_TAM" | "XAU_ONS" | "USD" | "EUR" | ..., code: "TRY" | "XAU_GRAM" | "XAU_CEYREK" | "XAU_YARIM" | "XAU_TAM" | "XAU_ONS" | "USD" | "EUR" | ...,
name: "Gram Altın", unit_label: "gr", 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 ### 3.13 `asset_transactions` — Alım / satım
```js ```js
@@ -174,7 +175,9 @@ Renkler ve ikonlar frontend tasarım token'larında.
note, source_period_id /* dönem kapanışından geldiyse */, note, source_period_id /* dönem kapanışından geldiyse */,
created_at, ... } 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 ### 3.14 `exchange_rates` — Kurlar
```js ```js
+5 -1
View File
@@ -1,10 +1,11 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ 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 ✅. **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 ## 📌 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. - **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ı. - **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. - **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 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 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 ✅.** - **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.
+3 -3
View File
@@ -12,7 +12,7 @@
| **Faz 5** | Günlük defter | Harcama girişi | 🔄 | | **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 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 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 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 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 | ⏳ | | **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 - ✅ 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 - ✅ 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) ## 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 - ✅ 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 - ⏳ 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ı - ⏳ 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 - ⏳ 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
+20
View File
@@ -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, };
+11
View File
@@ -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, };
+3
View File
@@ -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";
+16
View File
@@ -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, };
@@ -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, };
+3
View File
@@ -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";
@@ -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, };
+3
View File
@@ -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, };