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:
2026-11-12 09:07:51 +03:00
parent eca0bb27a0
commit 2003b8fad7
14 changed files with 1069 additions and 3 deletions
+26
View File
@@ -0,0 +1,26 @@
//! `entries` koleksiyonu — günlük defterdeki harcamalar (DATA_MODEL.md §3.9).
use mongodb::bson::Uuid;
use serde::Deserialize;
use crate::models::group::Group;
#[derive(Debug, Clone, Deserialize)]
pub struct Entry {
#[serde(rename = "_id")]
pub id: Uuid,
pub period_id: Uuid,
/// `"YYYY-MM-DD"`
pub date: String,
/// Kuruş, > 0
pub amount: i64,
/// Kategoriden türetilir (rapor ve bütçe toplamları için ayrıca saklanır)
pub group: Group,
pub category_id: Uuid,
#[serde(default)]
pub payment_method_id: Option<Uuid>,
#[serde(default)]
pub description: Option<String>,
pub created_by: Uuid,
pub version: i32,
}
+1
View File
@@ -2,6 +2,7 @@
//! Şema: `docs/DATA_MODEL.md`
pub mod common;
pub mod entry;
pub mod group;
pub mod household;
pub mod invite;
+203
View File
@@ -0,0 +1,203 @@
//! Günlük defter uç noktaları (hanenin tüm üyeleri):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/entries?from=&to=` | dönemin kayıtları (tarih aralığı isteğe bağlı, ikisi de dahil) |
//! | POST | `/api/periods/{id}/entries` | harcama ekle |
//! | PATCH | `/api/entries/{id}` | düzenle |
//! | DELETE | `/api/entries/{id}` | sil (soft) |
//! | POST | `/api/entries/{id}/restore` | silmeyi geri al |
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
models::{entry::Entry, group::Group},
services::entry::{self, EntryChanges, EntryFilter, NewEntry},
state::AppState,
};
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "EntryCreateRequest", optional_fields)]
pub struct CreateRequest {
/// `"YYYY-MM-DD"`, dönem içinde
date: String,
/// Kuruş, > 0
#[ts(type = "number")]
amount: i64,
/// Grup kategoriden alınır
category_id: String,
payment_method_id: Option<String>,
description: Option<String>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "EntryUpdateRequest", optional_fields)]
pub struct UpdateRequest {
date: Option<String>,
#[ts(type = "number", optional)]
amount: Option<i64>,
category_id: Option<String>,
/// Boş metin → ödeme türünü kaldır
payment_method_id: Option<String>,
/// Boş metin → açıklamayı kaldır
description: Option<String>,
version: Option<i32>,
}
#[derive(Deserialize)]
pub struct ListQuery {
from: Option<String>,
to: Option<String>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Entry")]
pub struct EntryDto {
id: String,
period_id: String,
date: String,
/// Kuruş
#[ts(type = "number")]
amount: i64,
group: Group,
category_id: String,
payment_method_id: Option<String>,
description: Option<String>,
created_by: String,
version: i32,
}
impl From<Entry> for EntryDto {
fn from(e: Entry) -> Self {
Self {
id: e.id.to_string(),
period_id: e.period_id.to_string(),
date: e.date,
amount: e.amount,
group: e.group,
category_id: e.category_id.to_string(),
payment_method_id: e.payment_method_id.map(|u| u.to_string()),
description: e.description,
created_by: e.created_by.to_string(),
version: e.version,
}
}
}
/// Adresteki kimlik → geçersizse 404
fn path_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
/// Gövdedeki kimlik → geçersizse 400 (ör. kategori seçilmemiş)
fn body_id(id: &str, what: &str) -> AppResult<Uuid> {
Uuid::parse_str(id.trim()).map_err(|_| AppError::BadRequest(format!("{what} geçersiz")))
}
pub async fn list(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Vec<EntryDto>>> {
let items = entry::list(
&s.db,
auth.household_id,
path_id(&period_id)?,
EntryFilter {
from: q.from,
to: q.to,
},
)
.await?;
Ok(Json(items.into_iter().map(EntryDto::from).collect()))
}
pub async fn create(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<EntryDto>)> {
let payment_method_id = match req.payment_method_id.as_deref().map(str::trim) {
None | Some("") => None,
Some(id) => Some(body_id(id, "Ödeme türü")?),
};
let e = entry::create(
&s.db,
auth.household_id,
auth.user_id,
path_id(&period_id)?,
NewEntry {
date: req.date,
amount: req.amount,
category_id: body_id(&req.category_id, "Kategori")?,
payment_method_id,
description: req.description,
},
)
.await?;
Ok((StatusCode::CREATED, Json(e.into())))
}
pub async fn update(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<EntryDto>> {
let payment_method_id = match req.payment_method_id.as_deref().map(str::trim) {
None => None,
Some("") => Some(None),
Some(pm) => Some(Some(body_id(pm, "Ödeme türü")?)),
};
let category_id = req
.category_id
.as_deref()
.map(|c| body_id(c, "Kategori"))
.transpose()?;
let e = entry::update(
&s.db,
auth.household_id,
path_id(&id)?,
EntryChanges {
date: req.date,
amount: req.amount,
category_id,
payment_method_id,
description: req.description,
version: req.version,
},
)
.await?;
Ok(Json(e.into()))
}
pub async fn delete(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
entry::delete(&s.db, auth.household_id, path_id(&id)?).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn restore(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<EntryDto>> {
let e = entry::restore(&s.db, auth.household_id, path_id(&id)?).await?;
Ok(Json(e.into()))
}
+7
View File
@@ -3,6 +3,7 @@
mod auth;
mod budget;
mod catalog;
mod entry;
mod health;
mod household;
mod note;
@@ -84,6 +85,12 @@ pub fn router(state: AppState) -> Router {
.route("/periods/{id}/budget/weeks", put(budget::set_week))
.route("/periods/{id}/budget/suggestion", get(budget::suggestion))
.route("/notes/{id}", patch(note::update).delete(note::delete))
.route(
"/periods/{id}/entries",
get(entry::list).post(entry::create),
)
.route("/entries/{id}", patch(entry::update).delete(entry::delete))
.route("/entries/{id}/restore", post(entry::restore))
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
.fallback(|| async { AppError::NotFound });
+12
View File
@@ -73,6 +73,18 @@ fn coll(db: &Database, c: Catalog) -> mongodb::Collection<Item> {
db.collection(c.collection())
}
/// Hanenin tek kaydı (pasifler dahil). Başka hanenin ya da olmayan kayıt → `None`.
pub async fn find(
db: &Database,
household_id: Uuid,
c: Catalog,
id: Uuid,
) -> AppResult<Option<Item>> {
let mut f = scope(household_id);
f.insert("_id", id);
Ok(coll(db, c).find_one(f).await?)
}
/// Hanenin kayıtları. Kategoriler grup sırasına, sonra `sort_order`'a göre sıralanır.
pub async fn list(
db: &Database,
+290
View File
@@ -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())
}
+1
View File
@@ -4,6 +4,7 @@ pub mod account;
pub mod budget;
pub mod calendar;
pub mod catalog;
pub mod entry;
pub mod invite;
pub mod money;
pub mod note;