feat(defter): harcama kayıtları — ekle, düzenle, sil, geri al (backend)
- /periods/{id}/entries (from/to filtresi), /entries/{id}, /entries/{id}/restore
- Grup kategoriden türetilir; kategori/ödeme türü hane ve aktiflik kontrolü
- Yalnızca açık dönem; soft delete + geri al; sürüm çakışmasında 409
- 6 entegrasyon testi (izolasyon, kapalı dönem, doğrulama, önceki dönem önerisi)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
//! Günlük defter: harcama kayıtları (KAKEIBO_PROMPT §8.4).
|
||||
//!
|
||||
//! Kurallar:
|
||||
//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı.
|
||||
//! - Grup **kategoriden** alınır (istemci grup göndermez) → kategori ile grup hiç çelişmez.
|
||||
//! - Kategori ve ödeme türü bu haneye ait olmalı; yeni seçimde **aktif** olmalı
|
||||
//! (pasife alınmış kategoride kalan eski kayıt düzenlenebilir).
|
||||
//! - Yalnızca açık dönemde yazma. Silme soft delete; "Geri al" için `restore`.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
options::ReturnDocument,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
entry::Entry,
|
||||
group::Group,
|
||||
period::Period,
|
||||
},
|
||||
services::{calendar::parse_date, catalog, period},
|
||||
};
|
||||
|
||||
/// Açıklama üst sınırı (karakter)
|
||||
pub const MAX_DESCRIPTION: usize = 200;
|
||||
|
||||
pub struct NewEntry {
|
||||
pub date: String,
|
||||
pub amount: i64,
|
||||
pub category_id: Uuid,
|
||||
pub payment_method_id: Option<Uuid>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EntryChanges {
|
||||
pub date: Option<String>,
|
||||
pub amount: Option<i64>,
|
||||
pub category_id: Option<Uuid>,
|
||||
/// `Some(None)` → ödeme türünü kaldır (istekte boş metin)
|
||||
pub payment_method_id: Option<Option<Uuid>>,
|
||||
/// `Some("")` → açıklamayı kaldır
|
||||
pub description: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
/// Liste filtresi: tarih aralığı (ikisi de dahil, isteğe bağlı)
|
||||
#[derive(Default)]
|
||||
pub struct EntryFilter {
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
}
|
||||
|
||||
fn coll(db: &Database) -> mongodb::Collection<Entry> {
|
||||
db.collection(col::ENTRIES)
|
||||
}
|
||||
|
||||
/// Dönemin kayıtları: en yeni tarih başta, aynı gün içinde en son eklenen başta.
|
||||
pub async fn list(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
filter: EntryFilter,
|
||||
) -> AppResult<Vec<Entry>> {
|
||||
period::get(db, household_id, period_id).await?; // dönem bu haneye ait mi?
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
let mut range = Document::new();
|
||||
if let Some(from) = filter.from {
|
||||
range.insert("$gte", valid_date(&from)?);
|
||||
}
|
||||
if let Some(to) = filter.to {
|
||||
range.insert("$lte", valid_date(&to)?);
|
||||
}
|
||||
if !range.is_empty() {
|
||||
f.insert("date", range);
|
||||
}
|
||||
// UUID v7 zamana göre sıralı → `_id` azalan = en son eklenen önce
|
||||
let mut cursor = coll(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)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
e: NewEntry,
|
||||
) -> AppResult<Entry> {
|
||||
let p = period::get_open(db, household_id, period_id).await?;
|
||||
let date = clean_date(&e.date, &p)?;
|
||||
let amount = clean_amount(e.amount)?;
|
||||
let group = category_group(db, household_id, e.category_id, None).await?;
|
||||
if let Some(pm) = e.payment_method_id {
|
||||
check_payment_method(db, household_id, pm, None).await?;
|
||||
}
|
||||
let description = clean_description(e.description.as_deref())?;
|
||||
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("period_id", period_id);
|
||||
d.insert("date", date);
|
||||
d.insert("amount", amount);
|
||||
d.insert("group", group.as_str());
|
||||
d.insert("category_id", e.category_id);
|
||||
d.insert(
|
||||
"payment_method_id",
|
||||
e.payment_method_id.map_or(Bson::Null, Bson::from),
|
||||
);
|
||||
d.insert("description", description.map_or(Bson::Null, Bson::String));
|
||||
d.insert("created_by", user_id);
|
||||
db.collection::<Document>(col::ENTRIES)
|
||||
.insert_one(d)
|
||||
.await?;
|
||||
coll(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen kayıt okunamadı".into()))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
ch: EntryChanges,
|
||||
) -> AppResult<Entry> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
let p = period::get_open(db, household_id, current.period_id).await?;
|
||||
if ch.version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
|
||||
let mut set = doc! { "updated_at": now() };
|
||||
if let Some(date) = ch.date {
|
||||
set.insert("date", clean_date(&date, &p)?);
|
||||
}
|
||||
if let Some(amount) = ch.amount {
|
||||
set.insert("amount", clean_amount(amount)?);
|
||||
}
|
||||
if let Some(cid) = ch.category_id {
|
||||
let group = category_group(db, household_id, cid, Some(current.category_id)).await?;
|
||||
set.insert("category_id", cid);
|
||||
set.insert("group", group.as_str());
|
||||
}
|
||||
if let Some(pm) = ch.payment_method_id {
|
||||
if let Some(pm) = pm {
|
||||
check_payment_method(db, household_id, pm, current.payment_method_id).await?;
|
||||
}
|
||||
set.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||
}
|
||||
if let Some(desc) = ch.description {
|
||||
set.insert(
|
||||
"description",
|
||||
clean_description(Some(&desc))?.map_or(Bson::Null, Bson::String),
|
||||
);
|
||||
}
|
||||
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
f.insert("version", current.version);
|
||||
coll(db)
|
||||
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or_else(stale)
|
||||
}
|
||||
|
||||
/// Soft delete. Silinen kayıt `restore` ile geri getirilebilir ("Geri al").
|
||||
pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
period::get_open(db, household_id, current.period_id).await?;
|
||||
let ts = now();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db)
|
||||
.update_one(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Silinmiş kaydı geri getirir. Silinmemiş ya da başka hanenin kaydı → 404.
|
||||
pub async fn restore(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Entry> {
|
||||
let f = doc! { "_id": id, "household_id": household_id, "deleted_at": { "$ne": null } };
|
||||
let deleted = coll(db)
|
||||
.find_one(f.clone())
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
period::get_open(db, household_id, deleted.period_id).await?;
|
||||
coll(db)
|
||||
.find_one_and_update(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": null, "updated_at": now() }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
// ---------- yardımcılar ----------
|
||||
|
||||
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Entry> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Kategori bu haneye ait mi, aktif mi (ya da kaydın zaten kullandığı kategori mi)? → grubu
|
||||
async fn category_group(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
category_id: Uuid,
|
||||
current: Option<Uuid>,
|
||||
) -> AppResult<Group> {
|
||||
let c = catalog::find(db, household_id, catalog::Catalog::Categories, category_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Kategori bulunamadı".into()))?;
|
||||
if !c.is_active && current != Some(category_id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu kategori pasif; başka bir kategori seçin".into(),
|
||||
));
|
||||
}
|
||||
c.group
|
||||
.ok_or_else(|| AppError::Internal("kategorinin grubu yok".into()))
|
||||
}
|
||||
|
||||
async fn check_payment_method(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
current: Option<Uuid>,
|
||||
) -> AppResult<()> {
|
||||
let m = catalog::find(db, household_id, catalog::Catalog::PaymentMethods, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?;
|
||||
if !m.is_active && current != Some(id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu ödeme türü pasif; başka birini seçin".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_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_date(s: &str, p: &Period) -> AppResult<String> {
|
||||
let s = valid_date(s)?;
|
||||
if s < p.start_date || s >= p.end_date {
|
||||
return Err(AppError::BadRequest("Tarih dönem içinde olmalı".into()));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn clean_amount(v: i64) -> AppResult<i64> {
|
||||
if v <= 0 {
|
||||
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
|
||||
}
|
||||
period::validate_amount(v)
|
||||
}
|
||||
|
||||
fn clean_description(d: Option<&str>) -> AppResult<Option<String>> {
|
||||
match d.map(str::trim) {
|
||||
None | Some("") => Ok(None),
|
||||
Some(s) if s.chars().count() > MAX_DESCRIPTION => Err(AppError::BadRequest(format!(
|
||||
"Açıklama en fazla {MAX_DESCRIPTION} 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())
|
||||
}
|
||||
Reference in New Issue
Block a user