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:
@@ -263,3 +263,39 @@ Content-Type: application/json
|
||||
|
||||
### Bütçe önerisi — method: default (varsayılan yüzdeler) | previous (önceki dönem) | avg3 (son 3 dönem)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget/suggestion?method=default
|
||||
|
||||
# ─────────────────────── Günlük defter (harcamalar) ───────────────────────
|
||||
# Kategori / ödeme türü kimlikleri: GET /categories, GET /payment-methods · tutarlar kuruş
|
||||
|
||||
### Dönemin harcamaları (en yeni başta)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/entries
|
||||
|
||||
### Bir haftanın harcamaları (from/to dahil)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/entries?from=2026-12-07&to=2026-12-13
|
||||
|
||||
### Harcama ekle — amount kuruş (345,50 ₺ = 34550); grup kategoriden gelir; ödeme türü ve açıklama isteğe bağlı
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/entries
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"date": "2026-12-09",
|
||||
"amount": 34550,
|
||||
"category_id": "BURAYA-KATEGORI-ID",
|
||||
"payment_method_id": "BURAYA-ODEME-TURU-ID",
|
||||
"description": "Haftalık market"
|
||||
}
|
||||
|
||||
### Harcamayı düzenle — "payment_method_id": "" / "description": "" → kaldır
|
||||
PATCH {{baseUrl}}/entries/BURAYA-KAYIT-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount": 36000,
|
||||
"version": 1
|
||||
}
|
||||
|
||||
### Harcamayı sil
|
||||
DELETE {{baseUrl}}/entries/BURAYA-KAYIT-ID
|
||||
|
||||
### Silmeyi geri al ("↩️ Geri al")
|
||||
POST {{baseUrl}}/entries/BURAYA-KAYIT-ID/restore
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Şema: `docs/DATA_MODEL.md`
|
||||
|
||||
pub mod common;
|
||||
pub mod entry;
|
||||
pub mod group;
|
||||
pub mod household;
|
||||
pub mod invite;
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Günlük defter: harcama kayıtları.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, Uuid, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Hanenin ilk (örnek) kategorisi, verilen grupta
|
||||
async fn category(app: &TestApp, s: &Session, group: &str) -> String {
|
||||
let (_, cats) = app.get("/categories", s).await;
|
||||
cats.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["group"] == group)
|
||||
.unwrap_or_else(|| panic!("{group} grubunda kategori yok"))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn payment_method(app: &TestApp, s: &Session) -> String {
|
||||
let (_, pms) = app.get("/payment-methods", s).await;
|
||||
pms[0]["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn add(app: &TestApp, s: &Session, pid: &str, body: Value) -> (StatusCode, Value) {
|
||||
app.post(&format!("/periods/{pid}/entries"), s, body).await
|
||||
}
|
||||
|
||||
async fn close(app: &TestApp, pid: &str) {
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(
|
||||
doc! { "_id": Uuid::parse_str(pid).unwrap() },
|
||||
doc! { "$set": { "status": "closed" } },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn ids(list: &Value) -> Vec<&str> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["description"].as_str().unwrap_or(""))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crud_group_from_category_and_ordering() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let wants = category(&app, &s, "wants").await;
|
||||
let pm = payment_method(&app, &s).await;
|
||||
|
||||
let (st, e) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-09", "amount": 34_550, "category_id": needs,
|
||||
"payment_method_id": pm, "description": " Haftalık market " }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||
assert_eq!(e["group"], "needs", "grup kategoriden gelir");
|
||||
assert_eq!(e["description"], "Haftalık market", "boşluklar kırpılır");
|
||||
assert_eq!(e["payment_method_id"], pm.as_str());
|
||||
assert_eq!(e["version"], 1);
|
||||
let id = e["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Ödeme türü ve açıklama isteğe bağlı
|
||||
for (date, desc) in [
|
||||
("2026-12-09", "aynı gün, sonra"),
|
||||
("2026-12-02", "erken"),
|
||||
("2026-12-20", "geç"),
|
||||
] {
|
||||
let (st, b) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": date, "amount": 1_000, "category_id": wants, "description": desc }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
assert!(b["payment_method_id"].is_null());
|
||||
}
|
||||
|
||||
// Sıra: yeni tarih önce; aynı gün içinde son eklenen önce
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(
|
||||
ids(&list),
|
||||
["geç", "aynı gün, sonra", "Haftalık market", "erken"]
|
||||
);
|
||||
|
||||
// Tarih aralığı (hafta) filtresi: iki uç dahil
|
||||
let (_, week) = app
|
||||
.get(
|
||||
&format!("/periods/{pid}/entries?from=2026-12-07&to=2026-12-13"),
|
||||
&s,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(ids(&week), ["aynı gün, sonra", "Haftalık market"]);
|
||||
let (st, _) = app
|
||||
.get(&format!("/periods/{pid}/entries?from=2026-13-01"), &s)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
// Düzenle: kategori değişince grup da değişir; boş metin → kaldır
|
||||
let (st, u) = app
|
||||
.patch(
|
||||
&format!("/entries/{id}"),
|
||||
&s,
|
||||
json!({ "amount": 40_000, "category_id": wants, "payment_method_id": "",
|
||||
"description": "", "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{u}");
|
||||
assert_eq!(u["amount"], 40_000);
|
||||
assert_eq!(u["group"], "wants");
|
||||
assert!(u["payment_method_id"].is_null());
|
||||
assert!(u["description"].is_null());
|
||||
assert_eq!(u["version"], 2);
|
||||
assert_eq!(u["date"], "2026-12-09", "gönderilmeyen alan değişmez");
|
||||
|
||||
// Eski sürümle düzenleme → 409
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/entries/{id}"),
|
||||
&s,
|
||||
json!({ "amount": 1, "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Sil → listede yok
|
||||
let (st, _) = app.delete(&format!("/entries/{id}"), &s).await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(list.as_array().unwrap().len(), 3);
|
||||
let (st, _) = app
|
||||
.patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND, "silinmiş kayıt düzenlenemez");
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_and_restore_undo() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let (_, e) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-03", "amount": 2_500, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let id = e["id"].as_str().unwrap();
|
||||
|
||||
// Silinmemiş kayıt geri alınamaz
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
app.delete(&format!("/entries/{id}"), &s).await;
|
||||
let (st, r) = app
|
||||
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
assert_eq!(r["amount"], 2_500);
|
||||
assert_eq!(r["version"], 3, "sil + geri al iki değişiklik");
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(
|
||||
list.as_array().unwrap().len(),
|
||||
1,
|
||||
"geri gelen kayıt listede"
|
||||
);
|
||||
|
||||
// İkinci kez geri alma → 404
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_errors_are_turkish_400s() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let base = json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs });
|
||||
let with = |k: &str, v: Value| {
|
||||
let mut b = base.clone();
|
||||
b[k] = v;
|
||||
b
|
||||
};
|
||||
|
||||
let cases = [
|
||||
(with("amount", json!(0)), "Tutar sıfırdan büyük olmalı"),
|
||||
(with("amount", json!(-5)), "Tutar sıfırdan büyük olmalı"),
|
||||
(
|
||||
with("date", json!("2027-01-01")),
|
||||
"Tarih dönem içinde olmalı",
|
||||
),
|
||||
(with("date", json!("05.12.2026")), "Tarih geçersiz"),
|
||||
(with("category_id", json!("")), "Kategori geçersiz"),
|
||||
(
|
||||
with("category_id", json!(Uuid::new().to_string())),
|
||||
"Kategori bulunamadı",
|
||||
),
|
||||
(
|
||||
with("payment_method_id", json!("xyz")),
|
||||
"Ödeme türü geçersiz",
|
||||
),
|
||||
(
|
||||
with("payment_method_id", json!(Uuid::new().to_string())),
|
||||
"Ödeme türü bulunamadı",
|
||||
),
|
||||
(
|
||||
with("description", json!("a".repeat(201))),
|
||||
"Açıklama en fazla 200 karakter olabilir",
|
||||
),
|
||||
];
|
||||
for (body, msg) in cases {
|
||||
let (st, b) = add(&app, &s, &pid, body.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}");
|
||||
assert_eq!(b["error"]["message"], msg, "{body}");
|
||||
}
|
||||
|
||||
// 200 karakter (emoji = 1 karakter) kabul
|
||||
let (st, _) = add(&app, &s, &pid, with("description", json!("🛒".repeat(200)))).await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
// Pasif kategori: yeni kayıtta olmaz, onu zaten kullanan kayıt düzenlenebilir
|
||||
let (_, e) = add(&app, &s, &pid, base.clone()).await;
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/categories/{needs}"),
|
||||
&s,
|
||||
json!({ "is_active": false }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let (st, b) = add(&app, &s, &pid, base.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
b["error"]["message"],
|
||||
"Bu kategori pasif; başka bir kategori seçin"
|
||||
);
|
||||
let (st, b) = app
|
||||
.patch(
|
||||
&format!("/entries/{}", e["id"].as_str().unwrap()),
|
||||
&s,
|
||||
json!({ "amount": 2_000, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::OK,
|
||||
"eski kaydın kendi kategorisi kalabilir: {b}"
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_period_is_read_only() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let (_, e) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let (_, gone) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-06", "amount": 500, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
let id = e["id"].as_str().unwrap();
|
||||
let gone_id = gone["id"].as_str().unwrap();
|
||||
app.delete(&format!("/entries/{gone_id}"), &s).await;
|
||||
close(&app, &pid).await;
|
||||
|
||||
let (st, _) = add(
|
||||
&app,
|
||||
&s,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-07", "amount": 1, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app
|
||||
.patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app.delete(&format!("/entries/{id}"), &s).await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{gone_id}/restore"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
// Okuma serbest
|
||||
let (st, list) = app.get(&format!("/periods/{pid}/entries"), &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(list.as_array().unwrap().len(), 1);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn entries_are_isolated_between_households() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("Ali", None).await;
|
||||
let b = app.register("Banu", None).await;
|
||||
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
|
||||
let b_pid = open_period(&app, &b, "2026-12-01", "2027-01-01").await;
|
||||
let a_cat = category(&app, &a, "needs").await;
|
||||
let a_pm = payment_method(&app, &a).await;
|
||||
let (_, e) = add(
|
||||
&app,
|
||||
&a,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1_000, "category_id": a_cat }),
|
||||
)
|
||||
.await;
|
||||
let id = e["id"].as_str().unwrap();
|
||||
|
||||
// B, A'nın dönemini/kaydını göremez, değiştiremez
|
||||
let (st, _) = app.get(&format!("/periods/{pid}/entries"), &b).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = add(
|
||||
&app,
|
||||
&b,
|
||||
&pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app
|
||||
.patch(&format!("/entries/{id}"), &b, json!({ "amount": 5 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app.delete(&format!("/entries/{id}"), &b).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
app.delete(&format!("/entries/{id}"), &a).await;
|
||||
let (st, _) = app
|
||||
.post(&format!("/entries/{id}/restore"), &b, json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::NOT_FOUND,
|
||||
"başkasının silinmiş kaydı geri alınamaz"
|
||||
);
|
||||
|
||||
// B kendi dönemine A'nın kategorisini / ödeme türünü kullanamaz
|
||||
let (st, body) = add(
|
||||
&app,
|
||||
&b,
|
||||
&b_pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["message"], "Kategori bulunamadı");
|
||||
let b_cat = category(&app, &b, "needs").await;
|
||||
let (st, body) = add(
|
||||
&app,
|
||||
&b,
|
||||
&b_pid,
|
||||
json!({ "date": "2026-12-05", "amount": 1, "category_id": b_cat, "payment_method_id": a_pm }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"]["message"], "Ödeme türü bulunamadı");
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
/// Bütçe önerisi (önceki dönem) artık API'den girilen gerçek kayıtları kullanır.
|
||||
#[tokio::test]
|
||||
async fn previous_suggestion_uses_real_entries() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
let needs = category(&app, &s, "needs").await;
|
||||
let culture = category(&app, &s, "culture").await;
|
||||
for (cat, amount) in [(&needs, 120_000), (&needs, 30_000), (&culture, 15_000)] {
|
||||
let (st, b) = add(
|
||||
&app,
|
||||
&s,
|
||||
&nov,
|
||||
json!({ "date": "2026-11-10", "amount": amount, "category_id": cat }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
}
|
||||
// Silinen kayıt toplama girmez
|
||||
let (_, del) = add(
|
||||
&app,
|
||||
&s,
|
||||
&nov,
|
||||
json!({ "date": "2026-11-11", "amount": 999_999, "category_id": needs }),
|
||||
)
|
||||
.await;
|
||||
app.delete(&format!("/entries/{}", del["id"].as_str().unwrap()), &s)
|
||||
.await;
|
||||
close(&app, &nov).await;
|
||||
|
||||
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let (_, sug) = app
|
||||
.get(
|
||||
&format!("/periods/{dec}/budget/suggestion?method=previous"),
|
||||
&s,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sug["available"], true, "{sug}");
|
||||
assert_eq!(
|
||||
sug["amounts"],
|
||||
json!({ "needs": 150_000, "wants": 0, "culture": 15_000, "unexpected": 0 })
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ Sıradaki Adım
|
||||
**Faz 5 – Adım 1:** Backend harcama kayıtları (`entries`): model (DATA_MODEL'e bak), `services/entry.rs` (create/update/delete soft + restore, list: dönem/hafta/tarih aralığı), doğrulama (tutar > 0, tarih açık dönem içinde, kategori hanenin ve aktif, grup = kategorinin grubu, ödeme türü hanenin), `routes/entry.rs`, TS tipleri, `tests/entries.rs` (CRUD, kapalı dönem, izolasyon, geri al). Ardından `budget::suggest` previous/avg3 testleri gerçek kayıtla.
|
||||
**Faz 5 – Adım 2:** Backend gerçekleşen harcamalar: dönem özetine `spent_total` + `remaining` (harcanabilir − harcanan), bütçe görünümüne grup×hafta `spent` (aggregation, saklanmaz), haftalık özet uç noktası ya da `budget` yanıtına ekleme (karar: mevcut `GET /budget` DTO'suna `spent` alanı). Testler: haftalara doğru dağılım, silinen kayıt sayılmaz, izolasyon.
|
||||
|
||||
## 📌 Alınan Kararlar
|
||||
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
|
||||
@@ -97,3 +97,6 @@
|
||||
- E2E artık ayrı sunucuda: `REGISTRATION=open PORT=8091 STATIC_DIR=frontend/dist MONGODB_URI=…/kakeibo_test_e2e` (kullanıcının dev verisine dokunulmaz; bitince DB silinir). 31 kontrol geçti; ekran görüntüsüyle 3 düzen sorunu bulundu/düzeltildi (dar satırlar, mobil tutar/tarih, hafta etiketi).
|
||||
- **Adım 6:** Notlar: `features/notes/` (`NotesSection` — Plan'da `context="planning"`, Değerlendirme'de `review` ile yeniden kullanılacak; `NoteEditor` Yaz/Önizle sekmeleri; zod şeması karakteri backend gibi sayar), `lib/notes.ts` (+ `useHousehold` yazar adı için), `components/markdown.tsx` (ham HTML yok, bağlantılar yeni sekme), `components/ui/textarea.tsx`, `index.css` `.markdown` stilleri. Yeni bağımlılık: react-markdown 10, remark-gfm 4. E2E 16 kontrol (XSS, 409 çakışma dahil). `DistributionBar` 0/0 durumunda "tamamı dağıtıldı" demiyor. **Faz 4 tamamlandı.**
|
||||
- Not: JS paketi 820 kB (gzip 254 kB; markdown ~200 kB) → Faz 10'da route bazlı lazy + Markdown'u ayrı parçaya al. `npm audit` uyarıları shadcn CLI bağımlılıklarından (deepmerge, minimatch), uygulamaya girmiyor; `--force` shadcn'i 1.0'a düşürür, yapılmadı.
|
||||
|
||||
### 2026-11-12 — Faz 5
|
||||
- **Adım 1:** Harcamalar backend: `models/entry.rs`, `services/entry.rs` (list + `from`/`to` filtresi, sıra `date` ↓ `_id` ↓; create/update/delete soft/`restore`), `routes/entry.rs` (5 uç nokta), `catalog::find`. Grup **kategoriden türetilir** (istemci göndermez). Pasif kategori/ödeme türü yeni seçimde reddedilir, kaydın mevcut değeri olarak kalabilir. Ödeme türü isteğe bağlı; düzenlemede `""` → kaldır (projedeki "boş metin = kaldır" kuralı; ts-rs `deserialize_with` desteklemediği için çift-Option yerine). `tests/entries.rs` (6) — `previous` önerisi artık gerçek kayıtla test edildi. Mutasyon: grup türetme bozulunca yakalandı; restore'da hane filtresi tek başına kaldırılınca `get_open` ikinci katman olarak korudu, ikisi birden kaldırılınca 2 test yakaladı. Backend 97 test.
|
||||
|
||||
+2
-2
@@ -54,8 +54,8 @@
|
||||
- ✅ 6. Frontend: notlar (Markdown, düzenle/önizle)
|
||||
|
||||
## Faz 5 Adımları (taslak)
|
||||
- ⏳ 1. Backend: harcama kayıtları (ekle/düzenle/sil + geri al, hafta/dönem filtresi, kategori–grup tutarlılığı, yalnızca açık dönem), testler
|
||||
- ⏳ 2. Backend: haftalık/aylık gerçekleşen (grup bazlı bütçe–harcanan–kalan), özet ve önerilere gerçekleşen verisi
|
||||
- ✅ 1. Backend: harcama kayıtları (ekle/düzenle/sil + geri al, hafta/dönem filtresi, kategori–grup tutarlılığı, yalnızca açık dönem), testler
|
||||
- 🔄 2. Backend: haftalık/aylık gerçekleşen (grup bazlı bütçe–harcanan–kalan), özet ve önerilere gerçekleşen verisi
|
||||
- ⏳ 3. Frontend: Defter — hafta seçici + hızlı giriş formu (tutar, 4 grup düğmesi, kategori, ödeme türü, tarih, açıklama; son seçim hatırlanır)
|
||||
- ⏳ 4. Frontend: haftanın girişleri (gün gün), düzenle/sil + "Geri al", haftalık özet
|
||||
- ⏳ 5. Canlı yedekleme (mongodump günlük, 14 gün) + geri yükleme denemesi
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Group } from "./Group";
|
||||
|
||||
export type Entry = { id: string, period_id: string, date: string,
|
||||
/**
|
||||
* Kuruş
|
||||
*/
|
||||
amount: number, group: Group, category_id: string, payment_method_id: string | null, description: string | null, created_by: string, version: number, };
|
||||
@@ -0,0 +1,15 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type EntryCreateRequest = {
|
||||
/**
|
||||
* `"YYYY-MM-DD"`, dönem içinde
|
||||
*/
|
||||
date: string,
|
||||
/**
|
||||
* Kuruş, > 0
|
||||
*/
|
||||
amount: number,
|
||||
/**
|
||||
* Grup kategoriden alınır
|
||||
*/
|
||||
category_id: string, payment_method_id?: string, description?: string, };
|
||||
@@ -0,0 +1,11 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type EntryUpdateRequest = { date?: string, amount?: number, category_id?: string,
|
||||
/**
|
||||
* Boş metin → ödeme türünü kaldır
|
||||
*/
|
||||
payment_method_id?: string,
|
||||
/**
|
||||
* Boş metin → açıklamayı kaldır
|
||||
*/
|
||||
description?: string, version?: number, };
|
||||
Reference in New Issue
Block a user