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
+60
View File
@@ -170,3 +170,63 @@ Content-Type: application/json
"savings_goal": 750000,
"version": 1
}
# ─────────────────────────── Plan kalemleri (sabit gelir / gider) ───────────────────────────
# kind: "income" | "expense" · amount kuruş (> 0) · date isteğe bağlı, dönem içinde
### Dönemin kalemleri (önce gelirler)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items
### Kalem ekle
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items
Content-Type: application/json
{
"kind": "expense",
"name": "Kira",
"amount": 1500000,
"date": "2026-12-05"
}
### Önceki dönemden kopyala (aynı tür + ad varsa atlanır; tarihler göreli günle taşınır)
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items/copy-previous
### Kalemi düzenle ("date": "" → tarihi kaldır)
PATCH {{baseUrl}}/plan-items/BURAYA-KALEM-ID
Content-Type: application/json
{
"amount": 1600000,
"version": 1
}
### Kalemi sil
DELETE {{baseUrl}}/plan-items/BURAYA-KALEM-ID
# ─────────────────────────── Notlar ───────────────────────────
# context: "planning" (Plan sayfası) | "review" (Ay sonu değerlendirmesi) · body_md: Markdown
### Notlar (?context=planning ile filtre)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/notes?context=planning
### Not ekle
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/notes
Content-Type: application/json
{
"context": "planning",
"title": "Tatil birikimi",
"body_md": "- Ocak'ta **5.000 ₺** ayır\n- Dışarıda yemeği azalt"
}
### Notu düzenle
PATCH {{baseUrl}}/notes/BURAYA-NOT-ID
Content-Type: application/json
{
"body_md": "Güncel metin",
"version": 1
}
### Notu sil
DELETE {{baseUrl}}/notes/BURAYA-NOT-ID
+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())
}
+375
View File
@@ -0,0 +1,375 @@
//! Plan kalemleri ve notlar.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Document, 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()
}
async fn close_period(app: &TestApp, id: &str) {
let uuid = mongodb::bson::Uuid::parse_str(id).unwrap();
app.db
.collection::<Document>("periods")
.update_one(
doc! { "_id": uuid },
doc! { "$set": { "status": "closed" } },
)
.await
.unwrap();
}
fn names(list: &Value) -> Vec<String> {
list.as_array()
.unwrap()
.iter()
.map(|i| {
format!(
"{}:{}",
i["kind"].as_str().unwrap(),
i["name"].as_str().unwrap()
)
})
.collect()
}
#[tokio::test]
async fn plan_items_crud_and_validation() {
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 items = format!("/periods/{pid}/plan-items");
// Gider önce eklense de liste gelirle başlar
let (st, rent) = app
.post(
&items,
&s,
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{rent}");
app.post(
&items,
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
)
.await;
app.post(
&items,
&s,
json!({ "kind": "expense", "name": "İnternet", "amount": 45_000 }),
)
.await;
let (_, list) = app.get(&items, &s).await;
assert_eq!(
names(&list),
vec!["income:Maaş", "expense:Kira", "expense:İnternet"]
);
assert_eq!(list[0]["date"], Value::Null);
// Doğrulamalar
for bad in [
json!({ "kind": "expense", "name": "X", "amount": 0 }),
json!({ "kind": "expense", "name": " ", "amount": 100 }),
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "2027-01-01" }), // dönem dışı (bitiş hariç)
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "05.12.2026" }),
json!({ "kind": "gift", "name": "X", "amount": 100 }),
] {
let (st, _) = app.post(&items, &s, bad.clone()).await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{bad}");
}
// Düzenle: tutar + tarihi kaldır; eski sürüm → 409
let rent_path = format!("/plan-items/{}", rent["id"].as_str().unwrap());
let (st, upd) = app
.patch(
&rent_path,
&s,
json!({ "amount": 1_600_000, "date": "", "version": 1 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{upd}");
assert_eq!(
(upd["amount"].as_i64(), &upd["date"]),
(Some(1_600_000), &Value::Null)
);
let (st, _) = app
.patch(&rent_path, &s, json!({ "name": "Y", "version": 1 }))
.await;
assert_eq!(st, StatusCode::CONFLICT);
// Sil → listeden çıkar
assert_eq!(app.delete(&rent_path, &s).await.0, StatusCode::NO_CONTENT);
let (_, list) = app.get(&items, &s).await;
assert_eq!(names(&list), vec!["income:Maaş", "expense:İnternet"]);
app.cleanup().await;
}
#[tokio::test]
async fn copy_previous_shifts_dates_and_skips_duplicates() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
let dec_items = format!("/periods/{dec}/plan-items");
app.post(
&dec_items,
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000, "date": "2026-12-15" }),
)
.await;
app.post(
&dec_items,
&s,
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
)
.await;
app.post(
&dec_items,
&s,
json!({ "kind": "expense", "name": "Aidat", "amount": 80_000 }),
)
.await;
app.post(
&dec_items,
&s,
json!({ "kind": "expense", "name": "Fatura", "amount": 90_000, "date": "2026-12-31" }),
)
.await;
close_period(&app, &dec).await;
let jan = open_period(&app, &s, "2027-01-01", "2027-02-01").await;
let jan_items = format!("/periods/{jan}/plan-items");
// Kullanıcı kirayı elle zaten eklemiş (farklı tutarla) → kopyada atlanmalı
app.post(
&jan_items,
&s,
json!({ "kind": "expense", "name": "kira", "amount": 1_700_000 }),
)
.await;
let (st, res) = app
.post(&format!("{jan_items}/copy-previous"), &s, json!({}))
.await;
assert_eq!(st, StatusCode::OK, "{res}");
assert_eq!(res["added"], 3);
let (_, list) = app.get(&jan_items, &s).await;
let by_name = |n: &str| {
list.as_array()
.unwrap()
.iter()
.find(|i| i["name"] == n)
.unwrap()
.clone()
};
assert_eq!(by_name("Maaş")["date"], "2027-01-15", "göreli gün korunur");
assert_eq!(by_name("Aidat")["date"], Value::Null);
assert_eq!(by_name("kira")["amount"], 1_700_000, "elle eklenen korunur");
assert_eq!(by_name("Fatura")["date"], "2027-01-31");
// Ocak → Şubat (28 gün): 31 Ocak'taki fatura dönem dışına taşar → son güne (28 Şubat) çekilir
close_period(&app, &jan).await;
let feb = open_period(&app, &s, "2027-02-01", "2027-03-01").await;
let feb_items = format!("/periods/{feb}/plan-items");
app.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
.await;
let (_, feb_list) = app.get(&feb_items, &s).await;
let fatura = feb_list
.as_array()
.unwrap()
.iter()
.find(|i| i["name"] == "Fatura")
.unwrap();
assert_eq!(fatura["date"], "2027-02-28");
// İkinci kez → hiçbir şey eklenmez (Ocak kapalı olduğu için Şubat'ta deniyoruz)
let (_, res) = app
.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
.await;
assert_eq!(res["added"], 0);
// İlk dönemde önceki yok → 400
let (st, _) = app
.post(&format!("{dec_items}/copy-previous"), &s, json!({}))
.await;
assert!(
st == StatusCode::BAD_REQUEST || st == StatusCode::CONFLICT,
"{st}"
);
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 (_, item) = app
.post(
&format!("/periods/{pid}/plan-items"),
&s,
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
)
.await;
let (_, note) = app
.post(
&format!("/periods/{pid}/notes"),
&s,
json!({ "context": "planning", "title": "Hedef" }),
)
.await;
close_period(&app, &pid).await;
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
let checks = [
app.post(
&format!("/periods/{pid}/plan-items"),
&s,
json!({ "kind": "income", "name": "X", "amount": 1 }),
)
.await
.0,
app.patch(&item_path, &s, json!({ "amount": 5 })).await.0,
app.delete(&item_path, &s).await.0,
app.post(
&format!("/periods/{pid}/notes"),
&s,
json!({ "context": "review", "title": "X" }),
)
.await
.0,
app.patch(&note_path, &s, json!({ "title": "Y" })).await.0,
app.delete(&note_path, &s).await.0,
];
assert!(
checks.iter().all(|st| *st == StatusCode::CONFLICT),
"{checks:?}"
);
// Okuma serbest
assert_eq!(
app.get(&format!("/periods/{pid}/plan-items"), &s).await.0,
StatusCode::OK
);
assert_eq!(
app.get(&format!("/periods/{pid}/notes"), &s).await.0,
StatusCode::OK
);
app.cleanup().await;
}
#[tokio::test]
async fn notes_crud_and_context_filter() {
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 notes = format!("/periods/{pid}/notes");
let (st, n) = app
.post(&notes, &s, json!({ "context": "planning", "title": " Tatil birikimi ", "body_md": "- Ocak'ta **5.000 ₺**" }))
.await;
assert_eq!(st, StatusCode::CREATED, "{n}");
assert_eq!(n["title"], "Tatil birikimi");
app.post(&notes, &s, json!({ "context": "review", "title": "Ders" }))
.await;
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
assert_eq!(planning.as_array().unwrap().len(), 1);
let (_, all) = app.get(&notes, &s).await;
assert_eq!(all.as_array().unwrap().len(), 2);
let (st, _) = app
.post(&notes, &s, json!({ "context": "planning", "title": "" }))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST, "boş başlık");
let path = format!("/notes/{}", n["id"].as_str().unwrap());
let (st, upd) = app
.patch(&path, &s, json!({ "body_md": "Güncel", "version": 1 }))
.await;
assert_eq!(
(st, upd["body_md"].as_str()),
(StatusCode::OK, Some("Güncel"))
);
assert_eq!(app.delete(&path, &s).await.0, StatusCode::NO_CONTENT);
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
assert_eq!(planning, json!([]));
app.cleanup().await;
}
#[tokio::test]
async fn plan_items_and_notes_are_isolated() {
let app = TestApp::new().await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
let (_, item) = app
.post(
&format!("/periods/{pid}/plan-items"),
&a,
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
)
.await;
let (_, note) = app
.post(
&format!("/periods/{pid}/notes"),
&a,
json!({ "context": "planning", "title": "Gizli" }),
)
.await;
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
let checks = [
app.get(&format!("/periods/{pid}/plan-items"), &b).await.0,
app.post(
&format!("/periods/{pid}/plan-items"),
&b,
json!({ "kind": "income", "name": "X", "amount": 1 }),
)
.await
.0,
app.post(
&format!("/periods/{pid}/plan-items/copy-previous"),
&b,
json!({}),
)
.await
.0,
app.patch(&item_path, &b, json!({ "amount": 1 })).await.0,
app.delete(&item_path, &b).await.0,
app.get(&format!("/periods/{pid}/notes"), &b).await.0,
app.patch(&note_path, &b, json!({ "title": "X" })).await.0,
app.delete(&note_path, &b).await.0,
];
assert!(
checks.iter().all(|st| *st == StatusCode::NOT_FOUND),
"{checks:?}"
);
// A'nın verisi değişmedi
let (_, list) = app.get(&format!("/periods/{pid}/plan-items"), &a).await;
assert_eq!(list[0]["amount"], 100);
assert_eq!(list[0]["version"], 1);
app.cleanup().await;
}
+5 -2
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**Faz 4 – Adım 2:** Backend plan kalemleri (`plan_items`: income/expense, ad, tutar kuruş, tarih dönem içinde, sort_order; CRUD + `POST /api/periods/{id}/plan-items/copy-previous`) ve notlar (`notes`: context planning|review, başlık, body_md; CRUD). Kapalı dönemde yazma yok. İzolasyon testleri.
**Faz 4 – Adım 3:** Backend bütçe: `budgets` (dönem × grup aylık tutar; haftalık yalnızca elle değiştirilirse `scope: week`), haftalık otomatik = aylık × hafta günü / dönem günü (kuruş yuvarlama: toplam aylığa eşit kalsın), öneri (`default` yüzdeler; `previous`/`avg3` entries gerektirir → şimdilik boş/varsayılana düşer), özet endpoint'i: gelir − sabit gider − birikim − devreden borç = harcanabilir; dağıtılan > harcanabilir uyarısı.
## 📌 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`).
@@ -38,10 +38,12 @@
- Canlı: domain `kakeibo.ta1sph.website` (DNS → 159.146.61.151, ev dış IP'si olmalı), Gitea Dokploy'a bağlı. Canlı MongoDB = ayrı Dokploy servisi `kakeibo-kakeibomongo-fibzhc` (rs0), kullanıcı `kakeibo_app` (readWrite/kakeibo).
- Dönem: bitiş hariç, en fazla 62 gün, hane başına tek açık, çakışma yok (metin karşılaştırması), kapalı salt okunur, daraltmada dışarıda harcama kalırsa 409. Ad boşsa otomatik (`calendar::default_period_name`). Tüm üyeler açabilir/düzenleyebilir.
- ts-rs: `i64` tutarlar `#[ts(type = "number")]`; isteklerde `Option<i64>` için `#[ts(type = "number", optional)]` (yoksa `?` kayboluyor).
- Plan kalemleri: tarih isteğe bağlı (dönem içinde), tutar > 0, kopyalama göreli gün + dönem sonuna kırpma, aynı tür+ad (büyük/küçük harf duyarsız) atlanır. Notlar: başlık 1–120, metin ≤ 20.000. Kapalı dönemde hepsi 409 (`period::get_open`). Silme = soft delete.
- İzolasyon iki katmanlı: kayıt okuma `scope()` + dönem `get_open(hid, period_id)` + yazma filtresi `scope()`. Tek katmanı bozmak açık oluşturmuyor (mutasyon denemesiyle görüldü).
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5, react-router 8, TanStack Query 5.
## ❓ Açık Konular
- **Geliştirme makinesinde Node çalışmıyor (2026-11-12):** CachyOS'ta simdjson 5.1.1 (.so.36) geldi, nodejs 26.13.0-2 hâlâ .so.34 istiyor. Çözüm: `sudo pacman -U /var/cache/pacman/pkg/simdjson-2:5.0.5-1-x86_64.pkg.tar.zst`, sonraki güncellemelerde `--ignore simdjson`. Faz 4 Adım 1'in frontend kontrolleri (tsc/vitest) Node düzelince çalıştırılacak.
- Geliştirme makinesi: CachyOS nodejs 26.13.0-2 ↔ simdjson .so.36 uyumsuzluğu → simdjson 2:5.0.5-1'e düşürüldü (2026-11-12). Düzeltilmiş nodejs gelene kadar `pacman -Syu --ignore simdjson`. `node: libsimdjson.so.34` hatası görülürse sebep budur.
- **Canlı yedekleme yok** (Faz 3 Adım 5 ertelendi) → gerçek veri girişinden önce, en geç Faz 5 sonunda: günlük mongodump, 14 yedek, geri yükleme testi.
- **Canlı MongoDB auth kapalı** (Dokploy replica set betiği keyFile kullanmıyor, özel Command'ı yok sayıyor). İç ağda, port kapalı. Ayrıntı: `docs/DEPLOY.md` §4.
- Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+).
@@ -84,3 +86,4 @@
### 2026-11-12 — Faz 4
- **Adım 1:** `services/calendar.rs` (parse/format, `weeks` Pazartesi başlangıçlı + kısmi haftalar, Türkçe ay adları; 4 test), `models/period.rs`, `services/period.rs` (2 test), `routes/period.rs` (list/current/create/get/patch; DTO'da `last_date`, `days`, `weeks`). `tests/periods.rs` (5) — çakışma kontrolü bilerek bozulunca test yakaladı. Backend toplam 58 test.
- **Adım 2:** `models/plan_item.rs`, `models/note.rs`, `services/plan.rs` (list/create/update/delete/copy_previous), `services/note.rs`, `routes/plan.rs`, `routes/note.rs`, `period::get_open` + `closed()`. `tests/plan_notes.rs` (5): CRUD + doğrulama, kopyalama (göreli gün, 28 Şubat'a kırpma, tekrar yok), kapalı dönem salt okunur, not filtresi, izolasyon. Backend 72 test.
+2 -2
View File
@@ -47,8 +47,8 @@
## Faz 4 Adımları
- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler
- ⏳ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
- ⏳ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
- ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
- 🔄 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
- ⏳ 4. Frontend: ilk dönem sihirbazı
- ⏳ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
- ⏳ 6. Frontend: notlar (Markdown, düzenle/önizle)
+7
View File
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CopyPreviousResult = {
/**
* Eklenen kalem sayısı (zaten var olanlar atlanır)
*/
added: number, };
+8
View File
@@ -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 { NoteContext } from "./NoteContext";
export type Note = { id: string, context: NoteContext, title: string, body_md: string, created_by: string,
/**
* ISO 8601
*/
updated_at: string, version: number, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Notun bağlı olduğu sayfa
*/
export type NoteContext = "planning" | "review";
+8
View File
@@ -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 { NoteContext } from "./NoteContext";
export type NoteCreateRequest = { context: NoteContext, title: string,
/**
* Markdown
*/
body_md: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NoteUpdateRequest = { title?: string, body_md?: string, version?: number, };
+8
View File
@@ -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 { PlanItemKind } from "./PlanItemKind";
export type PlanItem = { id: string, kind: PlanItemKind, name: string,
/**
* Kuruş
*/
amount: number, date: string | null, sort_order: number, version: number, };
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PlanItemKind } from "./PlanItemKind";
export type PlanItemCreateRequest = { kind: PlanItemKind, name: string,
/**
* Kuruş, > 0
*/
amount: number,
/**
* `"YYYY-MM-DD"`, dönem içinde (isteğe bağlı)
*/
date?: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PlanItemKind = "income" | "expense";
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PlanItemUpdateRequest = { name?: string, amount?: number,
/**
* Boş metin → tarihi kaldır
*/
date?: string, version?: number, };