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
+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;
}