3f60efdc33
- TCMB (döviz alış kuru) ve xaus.com (altın) saatlik arka planda çekilir, gün başına saklanır - Kaynak erişilemezse son kur geçerli; altın için son USD kuru kullanılır - Elle kur girişi/silme; aynı gün elle girilen kur önceliklidir - GET /portfolio: güncel değer, kâr/zarar, toplamlar, son kur çekimi - RATES_REFRESH_MINUTES ayarı Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
4.6 KiB
Rust
172 lines
4.6 KiB
Rust
//! Kur ve portföy uç noktaları (hanenin tüm üyeleri):
|
||
//!
|
||
//! | Yöntem | Yol | İş |
|
||
//! |---|---|---|
|
||
//! | GET | `/api/portfolio` | varlıklar + güncel kur, değer, kâr/zarar + toplamlar + son kur çekimi |
|
||
//! | POST | `/api/rates` | elle kur (hanenin; aynı gün tekrar → güncellenir) |
|
||
//! | DELETE | `/api/rates/{id}` | elle kuru sil → otomatik kura dön |
|
||
|
||
use axum::{
|
||
Json,
|
||
extract::{Path, State},
|
||
http::StatusCode,
|
||
};
|
||
use mongodb::bson::{DateTime, Uuid};
|
||
use serde::{Deserialize, Serialize};
|
||
use ts_rs::TS;
|
||
use validator::Validate;
|
||
|
||
use crate::{
|
||
auth::AuthUser,
|
||
error::{AppError, AppResult},
|
||
extract::ValidJson,
|
||
models::rate::Rate,
|
||
routes::asset::AssetDto,
|
||
services::rates,
|
||
};
|
||
|
||
#[derive(Serialize, TS)]
|
||
#[ts(export, rename = "Rate")]
|
||
pub struct RateDto {
|
||
id: String,
|
||
code: String,
|
||
/// TL × 1e6, birim başına
|
||
#[ts(type = "number")]
|
||
rate_e6: i64,
|
||
/// `tcmb` · `xaus` · `manual`
|
||
#[ts(type = "\"tcmb\" | \"xaus\" | \"manual\"")]
|
||
source: String,
|
||
rate_date: String,
|
||
/// ISO 8601
|
||
fetched_at: String,
|
||
is_manual: bool,
|
||
}
|
||
|
||
fn iso(d: DateTime) -> String {
|
||
d.try_to_rfc3339_string().unwrap_or_default()
|
||
}
|
||
|
||
impl From<Rate> for RateDto {
|
||
fn from(r: Rate) -> Self {
|
||
Self {
|
||
id: r.id.to_string(),
|
||
is_manual: r.is_manual(),
|
||
code: r.code,
|
||
rate_e6: r.rate_e6,
|
||
source: r.source,
|
||
rate_date: r.rate_date,
|
||
fetched_at: iso(r.fetched_at),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize, TS)]
|
||
#[ts(export, rename = "PortfolioItem")]
|
||
struct ItemDto {
|
||
asset: AssetDto,
|
||
/// TL'de ve kur yoksa `null`
|
||
rate: Option<RateDto>,
|
||
/// Güncel değer (kuruş); kur yoksa `null`
|
||
#[ts(type = "number | null")]
|
||
value: Option<i64>,
|
||
/// Değer − eldeki maliyet (kuruş)
|
||
#[ts(type = "number | null")]
|
||
unrealized_pl: Option<i64>,
|
||
}
|
||
|
||
#[derive(Serialize, TS)]
|
||
#[ts(export, rename = "Portfolio")]
|
||
pub struct PortfolioDto {
|
||
items: Vec<ItemDto>,
|
||
/// Eldeki maliyet toplamı (kuruş)
|
||
#[ts(type = "number")]
|
||
cost_total: i64,
|
||
/// Kuru olanların güncel değeri (kuruş)
|
||
#[ts(type = "number")]
|
||
value_total: i64,
|
||
/// Kuru olanlarda değer − maliyet
|
||
#[ts(type = "number")]
|
||
unrealized_pl_total: i64,
|
||
#[ts(type = "number")]
|
||
realized_pl_total: i64,
|
||
/// Elde olup kuru bulunmayan varlık sayısı (toplama girmedi)
|
||
unpriced: u32,
|
||
/// Son otomatik kur çekimi (ISO); hiç yoksa `null`
|
||
last_fetch: Option<String>,
|
||
}
|
||
|
||
pub async fn portfolio(
|
||
State(s): State<crate::state::AppState>,
|
||
auth: AuthUser,
|
||
) -> AppResult<Json<PortfolioDto>> {
|
||
let p = rates::portfolio(&s.db, auth.household_id).await?;
|
||
let mut dto = PortfolioDto {
|
||
items: Vec::with_capacity(p.items.len()),
|
||
cost_total: 0,
|
||
value_total: 0,
|
||
unrealized_pl_total: 0,
|
||
realized_pl_total: 0,
|
||
unpriced: 0,
|
||
last_fetch: p.last_fetch.map(iso),
|
||
};
|
||
for v in p.items {
|
||
let h = v.holding;
|
||
dto.cost_total += h.cost;
|
||
dto.realized_pl_total += h.realized_pl;
|
||
let unrealized = v.value.map(|x| x - h.cost);
|
||
match v.value {
|
||
Some(x) => {
|
||
dto.value_total += x;
|
||
dto.unrealized_pl_total += x - h.cost;
|
||
}
|
||
None if h.quantity_e6 > 0 => dto.unpriced += 1,
|
||
None => {}
|
||
}
|
||
dto.items.push(ItemDto {
|
||
asset: AssetDto::new(v.asset, h, v.tx_count),
|
||
rate: v.rate.map(RateDto::from),
|
||
value: v.value,
|
||
unrealized_pl: unrealized,
|
||
});
|
||
}
|
||
Ok(Json(dto))
|
||
}
|
||
|
||
#[derive(Deserialize, Validate, TS)]
|
||
#[ts(export, rename = "RateCreateRequest", optional_fields)]
|
||
pub struct CreateRequest {
|
||
/// Varlık kodu (`XAU_CEYREK`, `USD`…)
|
||
code: String,
|
||
/// TL × 1e6, birim başına
|
||
#[ts(type = "number")]
|
||
rate_e6: i64,
|
||
/// Boşsa bugün
|
||
rate_date: Option<String>,
|
||
}
|
||
|
||
pub async fn create(
|
||
State(s): State<crate::state::AppState>,
|
||
auth: AuthUser,
|
||
ValidJson(req): ValidJson<CreateRequest>,
|
||
) -> AppResult<(StatusCode, Json<RateDto>)> {
|
||
let r = rates::set_manual(
|
||
&s.db,
|
||
auth.household_id,
|
||
&req.code,
|
||
req.rate_e6,
|
||
req.rate_date.as_deref(),
|
||
)
|
||
.await?;
|
||
Ok((StatusCode::CREATED, Json(r.into())))
|
||
}
|
||
|
||
pub async fn delete(
|
||
State(s): State<crate::state::AppState>,
|
||
auth: AuthUser,
|
||
Path(id): Path<String>,
|
||
) -> AppResult<StatusCode> {
|
||
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
|
||
rates::delete_manual(&s.db, auth.household_id, id).await?;
|
||
Ok(StatusCode::NO_CONTENT)
|
||
}
|