feat(birikim): otomatik ve manuel kurlar, portföy değerlemesi
- 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>
This commit is contained in:
@@ -59,7 +59,7 @@ pub struct AssetDto {
|
||||
}
|
||||
|
||||
impl AssetDto {
|
||||
fn new(a: Asset, h: Holding, tx_count: usize) -> Self {
|
||||
pub(crate) fn new(a: Asset, h: Holding, tx_count: usize) -> Self {
|
||||
Self {
|
||||
id: a.id.to_string(),
|
||||
kind: a.kind,
|
||||
|
||||
@@ -10,6 +10,7 @@ mod household;
|
||||
mod note;
|
||||
mod period;
|
||||
mod plan;
|
||||
mod rates;
|
||||
mod review;
|
||||
mod spa;
|
||||
mod stats;
|
||||
@@ -100,6 +101,9 @@ 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("/portfolio", get(rates::portfolio))
|
||||
.route("/rates", post(rates::create))
|
||||
.route("/rates/{id}", delete(rates::delete))
|
||||
.route("/assets/{id}", patch(asset::update))
|
||||
.route(
|
||||
"/assets/{id}/transactions",
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! 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)
|
||||
}
|
||||
Reference in New Issue
Block a user