267d292a90
- 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>
340 lines
11 KiB
Rust
340 lines
11 KiB
Rust
//! 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;
|
||
}
|