feat(plan): sabit gelir/gider kalemleri ve dönem notları (backend)

- /api/periods/{id}/plan-items: listele, ekle; /api/plan-items/{id}: düzenle, sil
- Önceki dönemden kopyala: göreli gün korunur, dönem sonuna kırpılır, aynı kalem tekrar eklenmez
- /api/periods/{id}/notes (planning | review), /api/notes/{id}: Markdown notlar
- Kapalı dönemde değişiklik yok; silme geri alınabilir şekilde işaretlenir
- Entegrasyon testleri (5): kurallar, kopyalama, salt okunur dönem, hane izolasyonu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 03:34:35 +03:00
parent b883530f76
commit 7f3fde074c
23 changed files with 1337 additions and 8 deletions
+2
View File
@@ -5,5 +5,7 @@ pub mod common;
pub mod group;
pub mod household;
pub mod invite;
pub mod note;
pub mod period;
pub mod plan_item;
pub mod user;
+38
View File
@@ -0,0 +1,38 @@
//! `notes` koleksiyonu (DATA_MODEL.md §3.11). Notlar döneme özeldir.
use mongodb::bson::{DateTime, Uuid};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// Notun bağlı olduğu sayfa
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, rename = "NoteContext")]
pub enum NoteContext {
/// Aylık planlama
Planning,
/// Ay sonu değerlendirmesi
Review,
}
impl NoteContext {
pub fn as_str(self) -> &'static str {
match self {
NoteContext::Planning => "planning",
NoteContext::Review => "review",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Note {
#[serde(rename = "_id")]
pub id: Uuid,
pub period_id: Uuid,
pub context: NoteContext,
pub title: String,
pub body_md: String,
pub created_by: Uuid,
pub updated_at: DateTime,
pub version: i32,
}
+38
View File
@@ -0,0 +1,38 @@
//! `plan_items` koleksiyonu — sabit gelir / gider kalemleri (DATA_MODEL.md §3.7).
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, rename = "PlanItemKind")]
pub enum PlanKind {
Income,
Expense,
}
impl PlanKind {
pub fn as_str(self) -> &'static str {
match self {
PlanKind::Income => "income",
PlanKind::Expense => "expense",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct PlanItem {
#[serde(rename = "_id")]
pub id: Uuid,
pub period_id: Uuid,
pub kind: PlanKind,
pub name: String,
/// Kuruş
pub amount: i64,
/// `"YYYY-MM-DD"` (isteğe bağlı)
#[serde(default)]
pub date: Option<String>,
pub sort_order: i32,
pub version: i32,
}
+13
View File
@@ -4,7 +4,9 @@ mod auth;
mod catalog;
mod health;
mod household;
mod note;
mod period;
mod plan;
mod spa;
use axum::{
@@ -63,6 +65,17 @@ pub fn router(state: AppState) -> Router {
.route("/periods", get(period::list).post(period::create))
.route("/periods/current", get(period::current))
.route("/periods/{id}", get(period::get).patch(period::update))
.route(
"/periods/{id}/plan-items",
get(plan::list).post(plan::create),
)
.route(
"/periods/{id}/plan-items/copy-previous",
post(plan::copy_previous),
)
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
.route("/periods/{id}/notes", get(note::list).post(note::create))
.route("/notes/{id}", patch(note::update).delete(note::delete))
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
.fallback(|| async { AppError::NotFound });
+137
View File
@@ -0,0 +1,137 @@
//! Dönem notları:
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/notes?context=planning` | notlar (sayfa filtresi isteğe bağlı) |
//! | POST | `/api/periods/{id}/notes` | not ekle |
//! | PATCH | `/api/notes/{id}` | düzenle |
//! | DELETE | `/api/notes/{id}` | sil |
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::note::{Note, NoteContext},
services::note,
state::AppState,
};
#[derive(Deserialize)]
pub struct ListQuery {
context: Option<NoteContext>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "NoteCreateRequest")]
pub struct CreateRequest {
context: NoteContext,
title: String,
/// Markdown
#[serde(default)]
body_md: String,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "NoteUpdateRequest", optional_fields)]
pub struct UpdateRequest {
title: Option<String>,
body_md: Option<String>,
version: Option<i32>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Note")]
pub struct NoteDto {
id: String,
context: NoteContext,
title: String,
body_md: String,
created_by: String,
/// ISO 8601
updated_at: String,
version: i32,
}
impl From<Note> for NoteDto {
fn from(n: Note) -> Self {
Self {
id: n.id.to_string(),
context: n.context,
title: n.title,
body_md: n.body_md,
created_by: n.created_by.to_string(),
updated_at: n.updated_at.try_to_rfc3339_string().unwrap_or_default(),
version: n.version,
}
}
}
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
pub async fn list(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Vec<NoteDto>>> {
let notes = note::list(&s.db, auth.household_id, parse_id(&period_id)?, q.context).await?;
Ok(Json(notes.into_iter().map(NoteDto::from).collect()))
}
pub async fn create(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<NoteDto>)> {
let n = note::create(
&s.db,
auth.household_id,
auth.user_id,
parse_id(&period_id)?,
req.context,
&req.title,
&req.body_md,
)
.await?;
Ok((StatusCode::CREATED, Json(n.into())))
}
pub async fn update(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<NoteDto>> {
let n = note::update(
&s.db,
auth.household_id,
parse_id(&id)?,
req.title.as_deref(),
req.body_md.as_deref(),
req.version,
)
.await?;
Ok(Json(n.into()))
}
pub async fn delete(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
note::delete(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(StatusCode::NO_CONTENT)
}
+159
View File
@@ -0,0 +1,159 @@
//! Sabit gelir / gider kalemleri:
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/plan-items` | dönemin kalemleri |
//! | POST | `/api/periods/{id}/plan-items` | kalem ekle |
//! | POST | `/api/periods/{id}/plan-items/copy-previous` | önceki dönemden kopyala |
//! | PATCH | `/api/plan-items/{id}` | düzenle |
//! | DELETE | `/api/plan-items/{id}` | sil |
use axum::{
Json,
extract::{Path, 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::plan_item::{PlanItem, PlanKind},
services::plan::{self, ItemChanges, NewItem},
state::AppState,
};
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PlanItemCreateRequest", optional_fields)]
pub struct CreateRequest {
kind: PlanKind,
name: String,
/// Kuruş, > 0
#[ts(type = "number")]
amount: i64,
/// `"YYYY-MM-DD"`, dönem içinde (isteğe bağlı)
date: Option<String>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PlanItemUpdateRequest", optional_fields)]
pub struct UpdateRequest {
name: Option<String>,
#[ts(type = "number", optional)]
amount: Option<i64>,
/// Boş metin → tarihi kaldır
date: Option<String>,
version: Option<i32>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PlanItem")]
pub struct PlanItemDto {
id: String,
kind: PlanKind,
name: String,
/// Kuruş
#[ts(type = "number")]
amount: i64,
date: Option<String>,
sort_order: i32,
version: i32,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct CopyPreviousResult {
/// Eklenen kalem sayısı (zaten var olanlar atlanır)
added: usize,
}
impl From<PlanItem> for PlanItemDto {
fn from(i: PlanItem) -> Self {
Self {
id: i.id.to_string(),
kind: i.kind,
name: i.name,
amount: i.amount,
date: i.date,
sort_order: i.sort_order,
version: i.version,
}
}
}
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
pub async fn list(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
) -> AppResult<Json<Vec<PlanItemDto>>> {
let items = plan::list(&s.db, auth.household_id, parse_id(&period_id)?).await?;
Ok(Json(items.into_iter().map(PlanItemDto::from).collect()))
}
pub async fn create(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<PlanItemDto>)> {
let item = plan::create(
&s.db,
auth.household_id,
parse_id(&period_id)?,
NewItem {
kind: req.kind,
name: req.name,
amount: req.amount,
date: req.date,
},
)
.await?;
Ok((StatusCode::CREATED, Json(item.into())))
}
pub async fn copy_previous(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
) -> AppResult<Json<CopyPreviousResult>> {
let added = plan::copy_previous(&s.db, auth.household_id, parse_id(&period_id)?).await?;
Ok(Json(CopyPreviousResult { added }))
}
pub async fn update(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<PlanItemDto>> {
let item = plan::update(
&s.db,
auth.household_id,
parse_id(&id)?,
ItemChanges {
name: req.name,
amount: req.amount,
date: req.date,
version: req.version,
},
)
.await?;
Ok(Json(item.into()))
}
pub async fn delete(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
plan::delete(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(StatusCode::NO_CONTENT)
}
+2
View File
@@ -4,5 +4,7 @@ pub mod account;
pub mod calendar;
pub mod catalog;
pub mod invite;
pub mod note;
pub mod period;
pub mod plan;
pub mod seed;
+143
View File
@@ -0,0 +1,143 @@
//! Dönem notları (KAKEIBO_PROMPT §8.6): başlık + Markdown metin, `planning` ya da `review`.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
note::{Note, NoteContext},
},
services::period,
};
pub const MAX_BODY: usize = 20_000;
fn coll(db: &Database) -> mongodb::Collection<Note> {
db.collection(col::NOTES)
}
/// Dönemin notları (isteğe bağlı sayfa filtresiyle), en yeni değişen başta.
pub async fn list(
db: &Database,
household_id: Uuid,
period_id: Uuid,
context: Option<NoteContext>,
) -> AppResult<Vec<Note>> {
period::get(db, household_id, period_id).await?;
let mut f = scope(household_id);
f.insert("period_id", period_id);
if let Some(c) = context {
f.insert("context", c.as_str());
}
let mut cursor = coll(db).find(f).sort(doc! { "updated_at": -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,
context: NoteContext,
title: &str,
body_md: &str,
) -> AppResult<Note> {
period::get_open(db, household_id, period_id).await?;
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("context", context.as_str());
d.insert("title", clean_title(title)?);
d.insert("body_md", clean_body(body_md)?);
d.insert("created_by", user_id);
db.collection::<Document>(col::NOTES).insert_one(d).await?;
coll(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen not okunamadı".into()))
}
pub async fn update(
db: &Database,
household_id: Uuid,
id: Uuid,
title: Option<&str>,
body_md: Option<&str>,
version: Option<i32>,
) -> AppResult<Note> {
let current = get(db, household_id, id).await?;
period::get_open(db, household_id, current.period_id).await?;
if version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
if let Some(t) = title {
set.insert("title", clean_title(t)?);
}
if let Some(b) = body_md {
set.insert("body_md", clean_body(b)?);
}
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)
}
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);
db.collection::<Document>(col::NOTES)
.update_one(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Note> {
let mut f = scope(household_id);
f.insert("_id", id);
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
fn clean_title(t: &str) -> AppResult<String> {
let t = t.trim();
match t.chars().count() {
0 => Err(AppError::BadRequest("Not başlığı boş olamaz".into())),
1..=120 => Ok(t.to_string()),
_ => Err(AppError::BadRequest(
"Not başlığı en fazla 120 karakter olabilir".into(),
)),
}
}
fn clean_body(b: &str) -> AppResult<String> {
if b.chars().count() > MAX_BODY {
return Err(AppError::BadRequest(format!(
"Not metni en fazla {MAX_BODY} karakter olabilir"
)));
}
Ok(b.to_string())
}
fn stale() -> AppError {
AppError::Conflict("Not başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
+18 -4
View File
@@ -73,6 +73,22 @@ pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Perio
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
/// Yazma işlemleri için: dönem bu haneye ait ve **açık** olmalı.
/// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
let p = get(db, household_id, id).await?;
if p.status == PeriodStatus::Closed {
return Err(closed());
}
Ok(p)
}
pub fn closed() -> AppError {
AppError::Conflict(
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
)
}
pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResult<Period> {
let (start, end) = validate_range(&p.start_date, &p.end_date)?;
let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?;
@@ -135,9 +151,7 @@ pub async fn update(
) -> AppResult<Period> {
let current = get(db, household_id, id).await?;
if current.status == PeriodStatus::Closed {
return Err(AppError::Conflict(
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
));
return Err(closed());
}
if ch.version.is_some_and(|v| v != current.version) {
return Err(stale());
@@ -192,7 +206,7 @@ fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
Ok((start, end))
}
fn validate_amount(v: i64) -> AppResult<i64> {
pub fn validate_amount(v: i64) -> AppResult<i64> {
if !(0..=MAX_AMOUNT).contains(&v) {
return Err(AppError::BadRequest("Tutar geçersiz".into()));
}
+283
View File
@@ -0,0 +1,283 @@
//! Sabit gelir / gider kalemleri (KAKEIBO_PROMPT §8.3).
//!
//! - Tutar kuruş, > 0. Tarih isteğe bağlı; verilirse dönem içinde olmalı.
//! - Kapalı dönemde yazma yok.
//! - "Önceki dönemden kopyala": kalemler göreli günüyle taşınır (5 Ara → 5 Oca);
//! aynı tür + ad zaten varsa atlanır (iki kez basınca kopya oluşmaz).
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use time::Duration;
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
period::Period,
plan_item::{PlanItem, PlanKind},
},
services::{
calendar::{format_date, parse_date},
period,
},
};
pub struct NewItem {
pub kind: PlanKind,
pub name: String,
pub amount: i64,
pub date: Option<String>,
}
#[derive(Default)]
pub struct ItemChanges {
pub name: Option<String>,
pub amount: Option<i64>,
/// `Some("")` → tarihi kaldır
pub date: Option<String>,
pub version: Option<i32>,
}
fn coll(db: &Database) -> mongodb::Collection<PlanItem> {
db.collection(col::PLAN_ITEMS)
}
/// Dönemin kalemleri: önce gelirler, sonra giderler; her biri sıralı.
pub async fn list(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<PlanItem>> {
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 cursor = coll(db)
.find(f)
.sort(doc! { "kind": -1, "sort_order": 1 }) // "income" > "expense" → gelir önce
.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,
period_id: Uuid,
item: NewItem,
) -> AppResult<PlanItem> {
let p = period::get_open(db, household_id, period_id).await?;
let name = clean_name(&item.name)?;
let amount = clean_amount(item.amount)?;
let date = clean_date(item.date.as_deref(), &p)?;
let sort_order = next_sort_order(db, household_id, period_id, item.kind).await?;
insert(
db,
household_id,
period_id,
item.kind,
name,
amount,
date,
sort_order,
None,
)
.await
}
pub async fn update(
db: &Database,
household_id: Uuid,
id: Uuid,
ch: ItemChanges,
) -> AppResult<PlanItem> {
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(name) = ch.name {
set.insert("name", clean_name(&name)?);
}
if let Some(amount) = ch.amount {
set.insert("amount", clean_amount(amount)?);
}
if let Some(date) = ch.date {
set.insert(
"date",
clean_date(Some(&date), &p)?.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 (geçmiş/eşitleme için kayıt kalır).
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);
db.collection::<Document>(col::PLAN_ITEMS)
.update_one(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
/// Önceki dönemin kalemlerini bu döneme kopyalar. Eklenen kalem sayısını döner.
pub async fn copy_previous(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<usize> {
let target = period::get_open(db, household_id, period_id).await?;
// Bir önceki dönem: başlangıcı bu dönemden önce olan en yeni dönem
let mut pf = scope(household_id);
pf.insert("start_date", doc! { "$lt": &target.start_date });
let source = db
.collection::<Period>(col::PERIODS)
.find_one(pf)
.sort(doc! { "start_date": -1 })
.await?
.ok_or_else(|| AppError::BadRequest("Kopyalanacak önceki dönem yok".into()))?;
let existing = list(db, household_id, period_id).await?;
let src_items = list(db, household_id, source.id).await?;
let src_start = parse_date(&source.start_date).ok_or_else(bad_period)?;
let dst_start = parse_date(&target.start_date).ok_or_else(bad_period)?;
let dst_last = parse_date(&target.end_date).ok_or_else(bad_period)? - Duration::days(1);
let mut added = 0;
for item in src_items {
let duplicate = existing
.iter()
.any(|e| e.kind == item.kind && e.name.to_lowercase() == item.name.to_lowercase());
if duplicate {
continue;
}
// Göreli gün korunur, dönem dışına taşarsa son güne çekilir
let date = item.date.as_deref().and_then(parse_date).map(|d| {
let shifted = dst_start + (d - src_start);
format_date(shifted.clamp(dst_start, dst_last))
});
let sort_order = next_sort_order(db, household_id, period_id, item.kind).await?;
insert(
db,
household_id,
period_id,
item.kind,
item.name,
item.amount,
date,
sort_order,
Some(item.id),
)
.await?;
added += 1;
}
Ok(added)
}
// ---------- yardımcılar ----------
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<PlanItem> {
let mut f = scope(household_id);
f.insert("_id", id);
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
#[allow(clippy::too_many_arguments)]
async fn insert(
db: &Database,
household_id: Uuid,
period_id: Uuid,
kind: PlanKind,
name: String,
amount: i64,
date: Option<String>,
sort_order: i32,
copied_from: Option<Uuid>,
) -> AppResult<PlanItem> {
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("kind", kind.as_str());
d.insert("name", name);
d.insert("amount", amount);
d.insert("date", date.map_or(Bson::Null, Bson::String));
d.insert("sort_order", sort_order);
d.insert("copied_from_id", copied_from.map_or(Bson::Null, Bson::from));
db.collection::<Document>(col::PLAN_ITEMS)
.insert_one(d)
.await?;
coll(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen kalem okunamadı".into()))
}
async fn next_sort_order(
db: &Database,
household_id: Uuid,
period_id: Uuid,
kind: PlanKind,
) -> AppResult<i32> {
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert("kind", kind.as_str());
let last = coll(db).find_one(f).sort(doc! { "sort_order": -1 }).await?;
Ok(last.map_or(10, |i| i.sort_order + 10))
}
fn clean_name(name: &str) -> AppResult<String> {
let n = name.trim();
match n.chars().count() {
0 => Err(AppError::BadRequest("Kalem adı boş olamaz".into())),
1..=60 => Ok(n.to_string()),
_ => Err(AppError::BadRequest(
"Kalem adı en fazla 60 karakter olabilir".into(),
)),
}
}
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)
}
/// Boş → `None`; doluysa geçerli ve dönem içinde olmalı.
fn clean_date(date: Option<&str>, p: &Period) -> AppResult<Option<String>> {
match date.map(str::trim) {
None | Some("") => Ok(None),
Some(s) => {
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
if s < p.start_date.as_str() || s >= p.end_date.as_str() {
return Err(AppError::BadRequest("Tarih dönem içinde olmalı".into()));
}
Ok(Some(s.to_string()))
}
}
}
fn stale() -> AppError {
AppError::Conflict("Kalem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
fn bad_period() -> AppError {
AppError::Internal("dönem tarihi okunamadı".into())
}