48554e0ff6
- Kapatma sonucunda onayla "Birikime ekle" (TL alımı, dönemin son günü) - Birikimler'de "Bekleyen birikimler" listesi - Yeniden kapatmada bağlı kayıt yeni tutara güncellenir (0 → silinir), TL çıkışları aşılırsa reddedilir - Panel "Toplam birikim" artık portföyün güncel değeri Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
622 lines
20 KiB
Rust
622 lines
20 KiB
Rust
//! 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())
|
||
}
|
||
|
||
// ---------- Dönem kapanışından birikim ----------
|
||
|
||
/// Kapanışta birikime aktarılan tutarın TL varlığına eklenmiş kaydı (dönem başına tek)
|
||
pub async fn period_savings_tx(
|
||
db: &Database,
|
||
household_id: Uuid,
|
||
period_id: Uuid,
|
||
) -> AppResult<Option<AssetTx>> {
|
||
let mut f = scope(household_id);
|
||
f.insert("source_period_id", period_id);
|
||
Ok(txs(db).find_one(f).await?)
|
||
}
|
||
|
||
async fn cash_asset(db: &Database, household_id: Uuid) -> AppResult<Asset> {
|
||
let mut f = scope(household_id);
|
||
f.insert("kind", "cash");
|
||
assets(db)
|
||
.find_one(f)
|
||
.await?
|
||
.ok_or_else(|| AppError::Internal("hanenin TL varlığı yok".into()))
|
||
}
|
||
|
||
/// Kapalı dönemin `to_savings` tutarını TL varlığına **alım** olarak ekler (kullanıcı onayıyla).
|
||
/// Tarih = dönemin son günü. Aynı dönem ikinci kez eklenemez.
|
||
pub async fn add_period_savings(
|
||
db: &Database,
|
||
household_id: Uuid,
|
||
user_id: Uuid,
|
||
period_id: Uuid,
|
||
) -> AppResult<AssetTx> {
|
||
let p = crate::services::period::get(db, household_id, period_id).await?;
|
||
if p.status != crate::models::period::PeriodStatus::Closed {
|
||
return Err(AppError::BadRequest(
|
||
"Dönem kapanmadan birikime eklenemez".into(),
|
||
));
|
||
}
|
||
let amount = p.to_savings.unwrap_or(0);
|
||
if amount <= 0 {
|
||
return Err(AppError::BadRequest(
|
||
"Bu dönemde birikime aktarılan tutar yok".into(),
|
||
));
|
||
}
|
||
if period_savings_tx(db, household_id, period_id)
|
||
.await?
|
||
.is_some()
|
||
{
|
||
return Err(AppError::Conflict(
|
||
"Bu dönemin birikimi zaten eklendi".into(),
|
||
));
|
||
}
|
||
let cash = cash_asset(db, household_id).await?;
|
||
|
||
let mut d = new_meta(household_id);
|
||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||
d.insert("asset_id", cash.id);
|
||
d.insert("type", TxType::Buy.as_str());
|
||
d.insert("date", last_day(&p.end_date)?);
|
||
d.insert("quantity_e6", amount * 10_000);
|
||
d.insert("unit_price_e6", ONE_E6);
|
||
d.insert("total", amount);
|
||
d.insert("note", format!("{} kapanışı", p.name));
|
||
d.insert("source_period_id", period_id);
|
||
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()))
|
||
}
|
||
|
||
/// Kapalı, birikime aktarılan tutarı olan ama henüz eklenmemiş dönemler (en yeni başta)
|
||
pub async fn pending_period_savings(
|
||
db: &Database,
|
||
household_id: Uuid,
|
||
) -> AppResult<Vec<crate::models::period::Period>> {
|
||
let linked: Vec<Uuid> = {
|
||
let mut f = scope(household_id);
|
||
f.insert("source_period_id", doc! { "$ne": null });
|
||
let mut c = txs(db).find(f).await?;
|
||
let mut v = Vec::new();
|
||
while c.advance().await? {
|
||
if let Some(p) = c.deserialize_current()?.source_period_id {
|
||
v.push(p);
|
||
}
|
||
}
|
||
v
|
||
};
|
||
Ok(crate::services::period::list(db, household_id)
|
||
.await?
|
||
.into_iter()
|
||
.filter(|p| {
|
||
p.status == crate::models::period::PeriodStatus::Closed
|
||
&& p.to_savings.unwrap_or(0) > 0
|
||
&& !linked.contains(&p.id)
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// Yeniden kapatmada bağlı kayda yapılacak iş (transaction'dan önce hazırlanıp denetlenir)
|
||
pub enum SavingsSync {
|
||
Update { id: Uuid, amount: i64, date: String },
|
||
Delete { id: Uuid },
|
||
}
|
||
|
||
/// Bağlı kayıt varsa yeni tutar/tarihe göre işi hazırlar. Tutar 0 → silinir.
|
||
/// TL'de sonraki satışlar eldeki miktarı aşacaksa reddedilir (kapatma da yapılmaz).
|
||
pub async fn plan_savings_sync(
|
||
db: &Database,
|
||
household_id: Uuid,
|
||
period_id: Uuid,
|
||
amount: i64,
|
||
end_date: &str,
|
||
) -> AppResult<Option<SavingsSync>> {
|
||
let Some(linked) = period_savings_tx(db, household_id, period_id).await? else {
|
||
return Ok(None);
|
||
};
|
||
let date = last_day(end_date)?;
|
||
let mut list: Vec<AssetTx> = all_txs(db, household_id, Some(linked.asset_id))
|
||
.await?
|
||
.into_iter()
|
||
.filter(|x| x.id != linked.id)
|
||
.collect();
|
||
if amount > 0 {
|
||
let mut changed = linked.clone();
|
||
changed.date = date.clone();
|
||
changed.quantity_e6 = amount * 10_000;
|
||
changed.total = amount;
|
||
let pos = list.partition_point(|x| {
|
||
(x.date.as_str(), x.id.bytes()) <= (date.as_str(), linked.id.bytes())
|
||
});
|
||
list.insert(pos, changed);
|
||
}
|
||
if holding::holding(&moves(&list)).is_err() {
|
||
return Err(AppError::Conflict(
|
||
"Birikimden yapılan TL çıkışları yeni tutarı aşıyor; önce Birikimler'deki kayıtları düzeltin"
|
||
.into(),
|
||
));
|
||
}
|
||
Ok(Some(if amount > 0 {
|
||
SavingsSync::Update {
|
||
id: linked.id,
|
||
amount,
|
||
date,
|
||
}
|
||
} else {
|
||
SavingsSync::Delete { id: linked.id }
|
||
}))
|
||
}
|
||
|
||
/// Hazırlanan işi kapatma transaction'ı içinde uygular
|
||
pub async fn apply_savings_sync(
|
||
db: &Database,
|
||
session: &mut mongodb::ClientSession,
|
||
household_id: Uuid,
|
||
sync: &SavingsSync,
|
||
) -> AppResult<()> {
|
||
let ts = now();
|
||
let (id, set) = match sync {
|
||
SavingsSync::Update { id, amount, date } => (
|
||
*id,
|
||
doc! {
|
||
"date": date,
|
||
"quantity_e6": amount * 10_000,
|
||
"unit_price_e6": ONE_E6,
|
||
"total": amount,
|
||
"updated_at": ts,
|
||
},
|
||
),
|
||
SavingsSync::Delete { id } => (*id, doc! { "deleted_at": ts, "updated_at": ts }),
|
||
};
|
||
let mut f = scope(household_id);
|
||
f.insert("_id", id);
|
||
db.collection::<Document>(col::ASSET_TRANSACTIONS)
|
||
.update_one(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||
.session(session)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Hariç bitiş → son gün (dahil)
|
||
fn last_day(end_date: &str) -> AppResult<String> {
|
||
let end =
|
||
parse_date(end_date).ok_or_else(|| AppError::Internal("dönem bitişi geçersiz".into()))?;
|
||
Ok(crate::services::calendar::format_date(
|
||
end - time::Duration::days(1),
|
||
))
|
||
}
|