diff --git a/backend/api.http b/backend/api.http index 8a0f9e2..9b04987 100644 --- a/backend/api.http +++ b/backend/api.http @@ -230,3 +230,36 @@ Content-Type: application/json ### Notu sil DELETE {{baseUrl}}/notes/BURAYA-NOT-ID + +# ─────────────────────────── Bütçe ve özet ─────────────────────────── +# Gruplar: needs · wants · culture · unexpected · tutarlar kuruş + +### Dönem özeti: gelir − sabit gider − birikim − devreden borç + devreden artı = harcanabilir +GET {{baseUrl}}/periods/BURAYA-DONEM-ID/summary + +### Bütçe: 4 grup, aylık + haftalık döküm (haftalar gün sayısına göre) +GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget + +### Aylık bütçeleri kaydet (4 grup birlikte) +PUT {{baseUrl}}/periods/BURAYA-DONEM-ID/budget +Content-Type: application/json + +{ + "needs": 1500000, + "wants": 600000, + "culture": 200000, + "unexpected": 200000 +} + +### Bir haftayı elle ayarla (week_start = haftanın ilk günü); "amount": null → otomatiğe döner +PUT {{baseUrl}}/periods/BURAYA-DONEM-ID/budget/weeks +Content-Type: application/json + +{ + "group": "needs", + "week_start": "2026-12-07", + "amount": 400000 +} + +### 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 diff --git a/backend/src/routes/budget.rs b/backend/src/routes/budget.rs new file mode 100644 index 0000000..0d10c02 --- /dev/null +++ b/backend/src/routes/budget.rs @@ -0,0 +1,246 @@ +//! Bütçe ve özet uç noktaları: +//! +//! | Yöntem | Yol | İş | +//! |---|---|---| +//! | GET | `/api/periods/{id}/summary` | gelir/gider/birikim/devir → harcanabilir | +//! | GET | `/api/periods/{id}/budget` | 4 grup: aylık + haftalık döküm | +//! | PUT | `/api/periods/{id}/budget` | 4 grubun aylık tutarları | +//! | PUT | `/api/periods/{id}/budget/weeks` | bir haftayı elle ayarla / otomatiğe döndür | +//! | GET | `/api/periods/{id}/budget/suggestion?method=…` | `default` · `previous` · `avg3` | + +use axum::{ + Json, + extract::{Path, Query, State}, +}; +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::group::Group, + services::budget::{self, GroupBudget, SuggestMethod}, + state::AppState, +}; + +/// Gruplara göre tutarlar (kuruş) +#[derive(Serialize, Deserialize, Validate, TS, Clone, Copy)] +#[ts(export)] +pub struct GroupAmounts { + #[ts(type = "number")] + needs: i64, + #[ts(type = "number")] + wants: i64, + #[ts(type = "number")] + culture: i64, + #[ts(type = "number")] + unexpected: i64, +} + +impl From for GroupAmounts { + fn from(a: budget::GroupAmounts) -> Self { + Self { + needs: a[0], + wants: a[1], + culture: a[2], + unexpected: a[3], + } + } +} + +impl From for budget::GroupAmounts { + fn from(a: GroupAmounts) -> Self { + [a.needs, a.wants, a.culture, a.unexpected] + } +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "PeriodSummary")] +pub struct SummaryDto { + #[ts(type = "number")] + income_total: i64, + #[ts(type = "number")] + fixed_expense_total: i64, + #[ts(type = "number")] + savings_goal: i64, + /// Önceki dönemden devreden borç + #[ts(type = "number")] + carryover_debt: i64, + /// Önceki dönemden devreden artı bakiye + #[ts(type = "number")] + carryover_surplus: i64, + /// gelir − sabit gider − birikim − borç + artı + #[ts(type = "number")] + spendable: i64, + /// 4 grubun aylık bütçe toplamı + #[ts(type = "number")] + budget_total: i64, + /// harcanabilir − dağıtılan (negatifse fazla dağıtılmış) + #[ts(type = "number")] + unallocated: i64, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "GroupBudget")] +pub struct GroupBudgetDto { + group: Group, + #[ts(type = "number")] + monthly: i64, + /// Elle girilen haftaların toplamı aylık tutarı aşıyor + weekly_over: bool, + weeks: Vec, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "WeekBudget")] +struct WeekBudgetDto { + index: u32, + start_date: String, + last_date: String, + days: u32, + #[ts(type = "number")] + amount: i64, + /// Elle girildi (otomatik değil) + is_manual: bool, +} + +impl From for GroupBudgetDto { + fn from(g: GroupBudget) -> Self { + Self { + group: g.group, + monthly: g.monthly, + weekly_over: g.weekly_over, + weeks: g + .weeks + .into_iter() + .map(|w| WeekBudgetDto { + index: w.index, + start_date: w.start_date, + last_date: w.last_date, + days: w.days, + amount: w.amount, + is_manual: w.is_manual, + }) + .collect(), + } + } +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "BudgetWeekRequest")] +pub struct WeekRequest { + group: Group, + /// Haftanın ilk günü (`PeriodWeek.start_date`) + week_start: String, + /// Kuruş; `null` → otomatiğe döndür + #[ts(type = "number | null")] + amount: Option, +} + +#[derive(Deserialize, Clone, Copy, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, rename = "SuggestMethod")] +pub enum Method { + Default, + Previous, + Avg3, +} + +#[derive(Deserialize)] +pub struct SuggestQuery { + method: Option, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "BudgetSuggestion")] +pub struct SuggestionDto { + /// `false` → bu yöntem için veri yok (`message` nedenini söyler) + available: bool, + message: Option, + amounts: GroupAmounts, +} + +fn parse_id(id: &str) -> AppResult { + Uuid::parse_str(id).map_err(|_| AppError::NotFound) +} + +pub async fn summary( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult> { + let x = budget::summary(&s.db, auth.household_id, parse_id(&id)?).await?; + Ok(Json(SummaryDto { + income_total: x.income_total, + fixed_expense_total: x.fixed_expense_total, + savings_goal: x.savings_goal, + carryover_debt: x.carryover_debt, + carryover_surplus: x.carryover_surplus, + spendable: x.spendable, + budget_total: x.budget_total, + unallocated: x.spendable - x.budget_total, + })) +} + +pub async fn get( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult>> { + let groups = budget::view(&s.db, auth.household_id, parse_id(&id)?).await?; + Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect())) +} + +pub async fn set_monthly( + State(s): State, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult>> { + let pid = parse_id(&id)?; + budget::set_monthly(&s.db, auth.household_id, pid, req.into()).await?; + let groups = budget::view(&s.db, auth.household_id, pid).await?; + Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect())) +} + +pub async fn set_week( + State(s): State, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult>> { + let pid = parse_id(&id)?; + budget::set_week( + &s.db, + auth.household_id, + pid, + req.group, + &req.week_start, + req.amount, + ) + .await?; + let groups = budget::view(&s.db, auth.household_id, pid).await?; + Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect())) +} + +pub async fn suggestion( + State(s): State, + auth: AuthUser, + Path(id): Path, + Query(q): Query, +) -> AppResult> { + let method = match q.method.unwrap_or(Method::Default) { + Method::Default => SuggestMethod::Default, + Method::Previous => SuggestMethod::Previous, + Method::Avg3 => SuggestMethod::Avg3, + }; + let x = budget::suggest(&s.db, auth.household_id, parse_id(&id)?, method).await?; + Ok(Json(SuggestionDto { + available: x.available, + message: x.message, + amounts: x.amounts.into(), + })) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 4a735d8..587f2d8 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,6 +1,7 @@ //! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında. mod auth; +mod budget; mod catalog; mod health; mod household; @@ -75,6 +76,13 @@ pub fn router(state: AppState) -> Router { ) .route("/plan-items/{id}", patch(plan::update).delete(plan::delete)) .route("/periods/{id}/notes", get(note::list).post(note::create)) + .route("/periods/{id}/summary", get(budget::summary)) + .route( + "/periods/{id}/budget", + get(budget::get).put(budget::set_monthly), + ) + .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)) // Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin) .fallback(|| async { AppError::NotFound }); diff --git a/backend/src/services/budget.rs b/backend/src/services/budget.rs new file mode 100644 index 0000000..cd71388 --- /dev/null +++ b/backend/src/services/budget.rs @@ -0,0 +1,502 @@ +//! Bütçe ve dönem özeti (KAKEIBO_PROMPT §8.3, DATA_MODEL.md §3.8, §5). +//! +//! - Her grup için **aylık** tutar (`budgets`, `scope: "month"`). +//! - Haftalık tutar saklanmaz; aylık tutar hafta gün sayısına göre bölünür. +//! Kullanıcı bir haftayı elle değiştirirse yalnızca o kayıt saklanır (`scope: "week"`); +//! kalan tutar diğer haftalara gün oranında dağıtılır → haftaların toplamı = aylık. +//! - Özet: gelir − sabit gider − birikim − devreden borç + devreden artı = **harcanabilir**. + +use std::collections::HashMap; + +use mongodb::{ + Database, + bson::{Bson, Document, Uuid, doc}, +}; +use serde::Deserialize; + +use crate::{ + db::col, + error::{AppError, AppResult}, + models::{ + common::{new_meta, now, scope}, + group::Group, + period::{Period, PeriodStatus}, + }, + services::{ + calendar::{format_date, parse_date, weeks}, + money::split_proportional, + period, + }, +}; + +/// Gruplara göre tutarlar (kuruş), `Group::ALL` sırasıyla. +pub type GroupAmounts = [i64; 4]; + +pub struct Summary { + pub income_total: i64, + pub fixed_expense_total: i64, + pub savings_goal: i64, + pub carryover_debt: i64, + pub carryover_surplus: i64, + pub spendable: i64, + pub budget_total: i64, +} + +pub struct WeekBudget { + pub index: u32, + pub start_date: String, + pub last_date: String, + pub days: u32, + pub amount: i64, + pub is_manual: bool, +} + +pub struct GroupBudget { + pub group: Group, + pub monthly: i64, + pub weeks: Vec, + /// Elle girilen haftaların toplamı aylık tutarı aşıyor mu? + pub weekly_over: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SuggestMethod { + /// Hanenin varsayılan yüzdeleri × harcanabilir tutar + Default, + /// Önceki dönemde gerçekleşen harcamalar + Previous, + /// Son 3 dönemin gerçekleşen harcama ortalaması + Avg3, +} + +pub struct Suggestion { + pub available: bool, + pub message: Option, + pub amounts: GroupAmounts, +} + +#[derive(Deserialize)] +struct BudgetDoc { + group: Group, + scope: String, + #[serde(default)] + week_start: Option, + amount: i64, +} + +// ---------- Özet ---------- + +pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult { + let p = period::get(db, household_id, period_id).await?; + + let mut income_total = 0; + let mut fixed_expense_total = 0; + for (kind, total) in sum_by( + db, + col::PLAN_ITEMS, + household_id, + doc! { "period_id": period_id }, + "$kind", + ) + .await? + { + match kind.as_str() { + "income" => income_total = total, + "expense" => fixed_expense_total = total, + _ => {} + } + } + + let mut carryover_debt = 0; + let mut carryover_surplus = 0; + for (kind, total) in sum_by( + db, + col::CARRYOVERS, + household_id, + doc! { "to_period_id": period_id }, + "$kind", + ) + .await? + { + match kind.as_str() { + "debt" => carryover_debt = total, + "surplus" => carryover_surplus = total, + _ => {} + } + } + + let budget_total = monthly_amounts(db, household_id, period_id) + .await? + .iter() + .sum(); + let spendable = + income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus; + + Ok(Summary { + income_total, + fixed_expense_total, + savings_goal: p.savings_goal, + carryover_debt, + carryover_surplus, + spendable, + budget_total, + }) +} + +// ---------- Bütçe görünümü ---------- + +pub async fn view( + db: &Database, + household_id: Uuid, + period_id: Uuid, +) -> AppResult> { + let p = period::get(db, household_id, period_id).await?; + let docs = load(db, household_id, period_id).await?; + let (start, end) = dates(&p)?; + let period_weeks = weeks(start, end); + + let mut out = Vec::with_capacity(4); + for group in Group::ALL { + let monthly = docs + .iter() + .find(|d| d.group == group && d.scope == "month") + .map_or(0, |d| d.amount); + // Elle girilmiş haftalar (dönem tarihleri değiştiyse eşleşmeyen eski kayıtlar yok sayılır) + let manual: HashMap = docs + .iter() + .filter(|d| d.group == group && d.scope == "week") + .filter_map(|d| d.week_start.clone().map(|w| (w, d.amount))) + .collect(); + + let manual_total: i64 = period_weeks + .iter() + .filter_map(|w| manual.get(&format_date(w.start))) + .sum(); + let auto_days: Vec = period_weeks + .iter() + .filter(|w| !manual.contains_key(&format_date(w.start))) + .map(|w| u64::from(w.days)) + .collect(); + let mut auto = split_proportional(monthly - manual_total, &auto_days).into_iter(); + + let weeks = period_weeks + .iter() + .map(|w| { + let key = format_date(w.start); + let (amount, is_manual) = match manual.get(&key) { + Some(&a) => (a, true), + None => (auto.next().unwrap_or(0), false), + }; + WeekBudget { + index: w.index, + start_date: key, + last_date: format_date(w.last), + days: w.days, + amount, + is_manual, + } + }) + .collect(); + + out.push(GroupBudget { + group, + monthly, + weeks, + weekly_over: manual_total > monthly, + }); + } + Ok(out) +} + +// ---------- Yazma ---------- + +/// 4 grubun aylık tutarını birlikte kaydeder (elle girilmiş haftalar korunur). +pub async fn set_monthly( + db: &Database, + household_id: Uuid, + period_id: Uuid, + amounts: GroupAmounts, +) -> AppResult<()> { + period::get_open(db, household_id, period_id).await?; + for a in amounts { + period::validate_amount(a)?; + } + for (group, amount) in Group::ALL.into_iter().zip(amounts) { + upsert(db, household_id, period_id, group, "month", None, amount).await?; + } + Ok(()) +} + +/// Bir haftayı elle ayarlar; `amount = None` → otomatiğe döner. +pub async fn set_week( + db: &Database, + household_id: Uuid, + period_id: Uuid, + group: Group, + week_start: &str, + amount: Option, +) -> AppResult<()> { + let p = period::get_open(db, household_id, period_id).await?; + let (start, end) = dates(&p)?; + let valid = + parse_date(week_start).is_some_and(|d| weeks(start, end).iter().any(|w| w.start == d)); + if !valid { + return Err(AppError::BadRequest("Hafta bu döneme ait değil".into())); + } + match amount { + Some(a) => { + period::validate_amount(a)?; + upsert( + db, + household_id, + period_id, + group, + "week", + Some(week_start), + a, + ) + .await + } + None => { + let mut f = scope(household_id); + f.insert("period_id", period_id); + f.insert("group", group.as_str()); + f.insert("scope", "week"); + f.insert("week_start", week_start); + let ts = now(); + db.collection::(col::BUDGETS) + .update_many( + f, + doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } }, + ) + .await?; + Ok(()) + } + } +} + +// ---------- Öneri ---------- + +pub async fn suggest( + db: &Database, + household_id: Uuid, + period_id: Uuid, + method: SuggestMethod, +) -> AppResult { + let p = period::get(db, household_id, period_id).await?; + match method { + SuggestMethod::Default => { + let s = summary(db, household_id, period_id).await?; + if s.spendable <= 0 { + return Ok(unavailable( + "Harcanabilir tutar sıfır ya da negatif. Önce gelir, gider ve birikim hedefini girin.", + )); + } + let pct = split_percentages(db, household_id).await?; + let parts = split_proportional(s.spendable, &pct); + Ok(Suggestion { + available: true, + message: None, + amounts: [parts[0], parts[1], parts[2], parts[3]], + }) + } + SuggestMethod::Previous | SuggestMethod::Avg3 => { + let n = if method == SuggestMethod::Previous { + 1 + } else { + 3 + }; + let previous = previous_closed(db, household_id, &p, n).await?; + if previous.is_empty() { + return Ok(unavailable("Henüz kapanmış bir önceki dönem yok.")); + } + let ids: Vec = previous.iter().map(|id| Bson::from(*id)).collect(); + let sums = sum_by( + db, + col::ENTRIES, + household_id, + doc! { "period_id": { "$in": ids } }, + "$group", + ) + .await?; + let total: i64 = sums.values().sum(); + if total == 0 { + return Ok(unavailable("Önceki dönemlerde henüz harcama kaydı yok.")); + } + let count = previous.len() as i64; + let avg = |g: Group| { + let s = sums.get(g.as_str()).copied().unwrap_or(0); + (s + count / 2) / count // yuvarlayarak bölme + }; + Ok(Suggestion { + available: true, + message: None, + amounts: Group::ALL.map(avg), + }) + } + } +} + +// ---------- yardımcılar ---------- + +fn dates(p: &Period) -> AppResult<(time::Date, time::Date)> { + let bad = || AppError::Internal("dönem tarihi okunamadı".into()); + Ok(( + parse_date(&p.start_date).ok_or_else(bad)?, + parse_date(&p.end_date).ok_or_else(bad)?, + )) +} + +fn unavailable(msg: &str) -> Suggestion { + Suggestion { + available: false, + message: Some(msg.into()), + amounts: [0; 4], + } +} + +async fn load(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult> { + let mut f = scope(household_id); + f.insert("period_id", period_id); + let mut cursor = db.collection::(col::BUDGETS).find(f).await?; + let mut out = Vec::new(); + while cursor.advance().await? { + out.push(cursor.deserialize_current()?); + } + Ok(out) +} + +async fn monthly_amounts( + db: &Database, + household_id: Uuid, + period_id: Uuid, +) -> AppResult { + let docs = load(db, household_id, period_id).await?; + Ok(Group::ALL.map(|g| { + docs.iter() + .find(|d| d.group == g && d.scope == "month") + .map_or(0, |d| d.amount) + })) +} + +async fn upsert( + db: &Database, + household_id: Uuid, + period_id: Uuid, + group: Group, + level: &str, + week_start: Option<&str>, + amount: i64, +) -> AppResult<()> { + let coll = db.collection::(col::BUDGETS); + let mut f = scope(household_id); + f.insert("period_id", period_id); + f.insert("group", group.as_str()); + f.insert("scope", level); + f.insert( + "week_start", + week_start.map_or(Bson::Null, |w| Bson::String(w.into())), + ); + + let res = coll + .update_one( + f, + doc! { "$set": { "amount": amount, "updated_at": now() }, "$inc": { "version": 1 } }, + ) + .await?; + if res.matched_count == 0 { + let mut d = new_meta(household_id); + d.insert("period_id", period_id); + d.insert("group", group.as_str()); + d.insert("scope", level); + d.insert( + "week_start", + week_start.map_or(Bson::Null, |w| Bson::String(w.into())), + ); + d.insert("amount", amount); + coll.insert_one(d).await?; + } + Ok(()) +} + +/// Bir koleksiyonda `amount` alanını bir alana göre toplar: `{ "income": 450000, ... }` +async fn sum_by( + db: &Database, + collection: &str, + household_id: Uuid, + filter: Document, + key: &str, +) -> AppResult> { + let mut m = scope(household_id); + m.extend(filter); + let pipeline = vec![ + doc! { "$match": m }, + doc! { "$group": { "_id": key, "total": { "$sum": "$amount" } } }, + ]; + let mut cursor = db + .collection::(collection) + .aggregate(pipeline) + .await?; + let mut out = HashMap::new(); + while cursor.advance().await? { + let d = cursor.deserialize_current()?; + let k = d.get_str("_id").unwrap_or_default().to_string(); + let v = match d.get("total") { + Some(Bson::Int64(v)) => *v, + Some(Bson::Int32(v)) => i64::from(*v), + _ => 0, + }; + out.insert(k, v); + } + Ok(out) +} + +/// Hane ayarındaki varsayılan yüzdeler (yoksa 50/30/10/10). +async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult> { + let mut f = scope(household_id); + f.insert("_id", household_id); + let hh = db + .collection::(col::HOUSEHOLDS) + .find_one(f) + .await?; + let pct = hh + .as_ref() + .and_then(|h| h.get_document("settings").ok()) + .and_then(|s| s.get_document("budget_split_pct").ok()); + let default = [50u64, 30, 10, 10]; + Ok(Group::ALL + .iter() + .zip(default) + .map(|(g, d)| { + pct.and_then(|p| match p.get(g.as_str()) { + Some(Bson::Int32(v)) => Some(*v as u64), + Some(Bson::Int64(v)) => Some(*v as u64), + _ => None, + }) + .unwrap_or(d) + }) + .collect()) +} + +/// Bu dönemden önceki en yeni `n` kapalı dönemin kimlikleri. +async fn previous_closed( + db: &Database, + household_id: Uuid, + p: &Period, + n: i64, +) -> AppResult> { + let mut f = scope(household_id); + f.insert("start_date", doc! { "$lt": &p.start_date }); + f.insert("status", PeriodStatus::Closed.as_str()); + let mut cursor = db + .collection::(col::PERIODS) + .find(f) + .sort(doc! { "start_date": -1 }) + .limit(n) + .await?; + let mut out = Vec::new(); + while cursor.advance().await? { + out.push(cursor.deserialize_current()?.id); + } + Ok(out) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 0a972c5..44a7084 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,9 +1,11 @@ //! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar. pub mod account; +pub mod budget; pub mod calendar; pub mod catalog; pub mod invite; +pub mod money; pub mod note; pub mod period; pub mod plan; diff --git a/backend/src/services/money.rs b/backend/src/services/money.rs new file mode 100644 index 0000000..67fceec --- /dev/null +++ b/backend/src/services/money.rs @@ -0,0 +1,68 @@ +//! Para hesapları (kuruş, `i64`). Float yok. + +/// `total` kuruşu ağırlıklara orantılı böler; **toplam her zaman `total`'a eşittir**. +/// +/// "En büyük kalan" yöntemi: herkes tam payını alır, artan kuruşlar +/// küsuratı en büyük olana (eşitlikte öndekine) birer birer verilir. +/// Ağırlıkların hepsi 0 ise hepsine 0 döner (ve `total` dağıtılamaz → çağıran kontrol eder). +pub fn split_proportional(total: i64, weights: &[u64]) -> Vec { + let sum: u128 = weights.iter().map(|&w| u128::from(w)).sum(); + if sum == 0 || total <= 0 { + return vec![0; weights.len()]; + } + let total_u = total as u128; + let mut parts: Vec = Vec::with_capacity(weights.len()); + let mut remainders: Vec<(u128, usize)> = Vec::with_capacity(weights.len()); + for (i, &w) in weights.iter().enumerate() { + let exact = total_u * u128::from(w); + parts.push((exact / sum) as i64); + remainders.push((exact % sum, i)); + } + let mut left = total - parts.iter().sum::(); + // Büyük kalan önce; eşitlikte küçük indeks önce + remainders.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + for (_, i) in remainders { + if left == 0 { + break; + } + parts[i] += 1; + left -= 1; + } + parts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sums_exactly_and_is_proportional() { + // 1.000,00 ₺ Aralık haftalarına (6,7,7,7,4 gün) + let p = split_proportional(100_000, &[6, 7, 7, 7, 4]); + assert_eq!(p.iter().sum::(), 100_000); + assert_eq!(p, vec![19_355, 22_581, 22_581, 22_580, 12_903]); + } + + #[test] + fn default_percentages() { + // Tam paylar 617.283,5 / 370.370,1 / 123.456,7 / 123.456,7 → artan 2 kuruş + // küsuratı en büyük olan ikisine (0,7'ler) gider + assert_eq!( + split_proportional(1_234_567, &[50, 30, 10, 10]), + vec![617_283, 370_370, 123_457, 123_457] + ); + assert_eq!(split_proportional(1, &[50, 30, 10, 10]), vec![1, 0, 0, 0]); + } + + #[test] + fn edge_cases() { + assert_eq!(split_proportional(0, &[1, 2]), vec![0, 0]); + assert_eq!(split_proportional(-500, &[1, 2]), vec![0, 0]); + assert_eq!(split_proportional(100, &[0, 0]), vec![0, 0]); + assert_eq!(split_proportional(100, &[]), Vec::::new()); + assert_eq!(split_proportional(7, &[0, 3, 0]), vec![0, 7, 0]); + // Çok büyük tutarlarda taşma yok + let big = 10_000_000_000_000; + assert_eq!(split_proportional(big, &[31, 31]).iter().sum::(), big); + } +} diff --git a/backend/tests/budget.rs b/backend/tests/budget.rs new file mode 100644 index 0000000..f1254f4 --- /dev/null +++ b/backend/tests/budget.rs @@ -0,0 +1,403 @@ +//! Bütçe ve dönem özeti. + +mod common; + +use axum::http::StatusCode; +use common::{Session, TestApp}; +use mongodb::bson::{Document, Uuid, doc}; +use serde_json::{Value, json}; + +async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String { + let (st, p) = app + .post( + "/periods", + s, + json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{p}"); + p["id"].as_str().unwrap().to_string() +} + +async fn add_item(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) { + let (st, b) = app + .post( + &format!("/periods/{pid}/plan-items"), + s, + json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{b}"); +} + +fn group<'a>(budget: &'a Value, code: &str) -> &'a Value { + budget + .as_array() + .unwrap() + .iter() + .find(|g| g["group"] == code) + .unwrap() +} + +fn week_amounts(g: &Value) -> Vec { + g["weeks"] + .as_array() + .unwrap() + .iter() + .map(|w| w["amount"].as_i64().unwrap()) + .collect() +} + +#[tokio::test] +async fn summary_computes_spendable_with_carryovers() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let pid = open_december(&app, &s, 500_000).await; + + // Boş dönem: gelir yok → harcanabilir = -birikim + let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; + assert_eq!(sum["spendable"], -500_000); + + add_item(&app, &s, &pid, "income", 4_500_000).await; + add_item(&app, &s, &pid, "income", 300_000).await; + add_item(&app, &s, &pid, "expense", 1_500_000).await; + add_item(&app, &s, &pid, "expense", 45_000).await; + + // Önceki dönemden devir (Faz 6'da kapanış sihirbazı yazacak) + let hid = Uuid::parse_str(&s.household_id).unwrap(); + let to = Uuid::parse_str(&pid).unwrap(); + app.db + .collection::("carryovers") + .insert_many([ + doc! { "household_id": hid, "to_period_id": to, "kind": "debt", "amount": 100_000_i64, "deleted_at": null }, + doc! { "household_id": hid, "to_period_id": to, "kind": "surplus", "amount": 20_000_i64, "deleted_at": null }, + ]) + .await + .unwrap(); + + let (st, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; + assert_eq!(st, StatusCode::OK); + assert_eq!( + sum, + json!({ + "income_total": 4_800_000, + "fixed_expense_total": 1_545_000, + "savings_goal": 500_000, + "carryover_debt": 100_000, + "carryover_surplus": 20_000, + // 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000 + "spendable": 2_675_000, + "budget_total": 0, + "unallocated": 2_675_000 + }) + ); + + app.cleanup().await; +} + +#[tokio::test] +async fn default_suggestion_splits_spendable() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let pid = open_december(&app, &s, 0).await; + let url = format!("/periods/{pid}/budget/suggestion?method=default"); + + // Gelir yokken öneri yok + let (_, sug) = app.get(&url, &s).await; + assert_eq!(sug["available"], false); + assert!(sug["message"].as_str().unwrap().contains("Harcanabilir")); + + add_item(&app, &s, &pid, "income", 1_234_567).await; + let (_, sug) = app.get(&url, &s).await; + assert_eq!(sug["available"], true); + assert_eq!( + sug["amounts"], + json!({ "needs": 617_283, "wants": 370_370, "culture": 123_457, "unexpected": 123_457 }) + ); + + // Önceki dönem yokken previous/avg3 → veri yok + for m in ["previous", "avg3"] { + let (_, sug) = app + .get(&format!("/periods/{pid}/budget/suggestion?method={m}"), &s) + .await; + assert_eq!(sug["available"], false, "{m}"); + } + + app.cleanup().await; +} + +#[tokio::test] +async fn monthly_budget_is_split_into_weeks_by_days() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let pid = open_december(&app, &s, 0).await; + add_item(&app, &s, &pid, "income", 300_000).await; + let url = format!("/periods/{pid}/budget"); + + // Başta hepsi 0 + let (_, b) = app.get(&url, &s).await; + assert_eq!(b.as_array().unwrap().len(), 4); + assert_eq!(week_amounts(group(&b, "needs")), vec![0; 5]); + + let (st, b) = app + .put( + &url, + &s, + json!({ "needs": 100_000, "wants": 50_000, "culture": 31, "unexpected": 0 }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{b}"); + let needs = group(&b, "needs"); + assert_eq!(needs["monthly"], 100_000); + // Aralık: 6,7,7,7,4 gün → toplam tam 100.000 + assert_eq!( + week_amounts(needs), + vec![19_355, 22_581, 22_581, 22_580, 12_903] + ); + assert_eq!(week_amounts(group(&b, "culture")), vec![6, 7, 7, 7, 4]); + for g in b.as_array().unwrap() { + assert_eq!( + week_amounts(g).iter().sum::(), + g["monthly"].as_i64().unwrap() + ); + } + + // Özet: 150.031 dağıtıldı, harcanabilir 300.000 → kalan 149.969 + let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; + assert_eq!( + (sum["budget_total"].as_i64(), sum["unallocated"].as_i64()), + (Some(150_031), Some(149_969)) + ); + + // Fazla dağıtım → kalan negatif (arayüz uyarı gösterir) + app.put( + &url, + &s, + json!({ "needs": 400_000, "wants": 0, "culture": 0, "unexpected": 0 }), + ) + .await; + let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; + assert_eq!(sum["unallocated"], -100_000); + + // Negatif tutar → 400 + let (st, _) = app + .put( + &url, + &s, + json!({ "needs": -1, "wants": 0, "culture": 0, "unexpected": 0 }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + + app.cleanup().await; +} + +#[tokio::test] +async fn manual_week_override_redistributes_the_rest() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let pid = open_december(&app, &s, 0).await; + app.put( + &format!("/periods/{pid}/budget"), + &s, + json!({ "needs": 100_000, "wants": 0, "culture": 0, "unexpected": 0 }), + ) + .await; + let weeks_url = format!("/periods/{pid}/budget/weeks"); + + // 2. hafta (7–13 Ara) elle 50.000 → kalan 50.000 diğer haftalara (6+7+7+4 = 24 gün) + let (st, b) = app + .put( + &weeks_url, + &s, + json!({ "group": "needs", "week_start": "2026-12-07", "amount": 50_000 }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{b}"); + let needs = group(&b, "needs"); + assert_eq!( + week_amounts(needs), + vec![12_500, 50_000, 14_584, 14_583, 8_333] + ); + assert_eq!(week_amounts(needs).iter().sum::(), 100_000); + let manual: Vec = needs["weeks"] + .as_array() + .unwrap() + .iter() + .map(|w| w["is_manual"].as_bool().unwrap()) + .collect(); + assert_eq!(manual, vec![false, true, false, false, false]); + assert_eq!(needs["weekly_over"], false); + + // Aylık tutar değişince elle girilen hafta korunur + let (_, b) = app + .put( + &format!("/periods/{pid}/budget"), + &s, + json!({ "needs": 74_000, "wants": 0, "culture": 0, "unexpected": 0 }), + ) + .await; + assert_eq!(week_amounts(group(&b, "needs"))[1], 50_000); + assert_eq!(week_amounts(group(&b, "needs")).iter().sum::(), 74_000); + + // Elle girilenler aylığı aşarsa uyarı; diğer haftalar 0 + let (_, b) = app + .put( + &weeks_url, + &s, + json!({ "group": "needs", "week_start": "2026-12-14", "amount": 30_000 }), + ) + .await; + let needs = group(&b, "needs"); + assert_eq!(needs["weekly_over"], true); + assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0, 0]); + + // null → otomatiğe dön + app.put( + &weeks_url, + &s, + json!({ "group": "needs", "week_start": "2026-12-14", "amount": null }), + ) + .await; + let (_, b) = app + .put( + &weeks_url, + &s, + json!({ "group": "needs", "week_start": "2026-12-07", "amount": null }), + ) + .await; + assert_eq!(week_amounts(group(&b, "needs")).iter().sum::(), 74_000); + assert!( + group(&b, "needs")["weeks"] + .as_array() + .unwrap() + .iter() + .all(|w| w["is_manual"] == false) + ); + + // Haftanın ilk günü olmayan tarih → 400 + let (st, _) = app + .put( + &weeks_url, + &s, + json!({ "group": "needs", "week_start": "2026-12-08", "amount": 1 }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + + app.cleanup().await; +} + +#[tokio::test] +async fn previous_and_avg3_suggestions_use_actual_spending() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let hid = Uuid::parse_str(&s.household_id).unwrap(); + + // Ekim ve Kasım kapalı, harcamaları var (Faz 5'te defterden gelecek) + let mut closed = Vec::new(); + for (start, end) in [("2026-10-01", "2026-11-01"), ("2026-11-01", "2026-12-01")] { + let (_, p) = app + .post( + "/periods", + &s, + json!({ "start_date": start, "end_date": end }), + ) + .await; + let id = Uuid::parse_str(p["id"].as_str().unwrap()).unwrap(); + app.db + .collection::("periods") + .update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } }) + .await + .unwrap(); + closed.push(id); + } + let entry = |pid: Uuid, group: &str, amount: i64| { + doc! { "household_id": hid, "period_id": pid, "group": group, "amount": amount, "date": "2026-10-10", "deleted_at": null } + }; + app.db + .collection::("entries") + .insert_many([ + entry(closed[0], "needs", 100_000), + entry(closed[0], "wants", 41_000), + entry(closed[1], "needs", 200_000), + entry(closed[1], "needs", 50_000), + entry(closed[1], "culture", 10_000), + ]) + .await + .unwrap(); + + let pid = open_december(&app, &s, 0).await; + let (_, prev) = app + .get( + &format!("/periods/{pid}/budget/suggestion?method=previous"), + &s, + ) + .await; + assert_eq!(prev["available"], true, "{prev}"); + assert_eq!( + prev["amounts"], + json!({ "needs": 250_000, "wants": 0, "culture": 10_000, "unexpected": 0 }) + ); + + // avg3: 2 kapalı dönem var → ikiye bölünür (yuvarlayarak) + let (_, avg) = app + .get(&format!("/periods/{pid}/budget/suggestion?method=avg3"), &s) + .await; + assert_eq!( + avg["amounts"], + json!({ "needs": 175_000, "wants": 20_500, "culture": 5_000, "unexpected": 0 }) + ); + + app.cleanup().await; +} + +#[tokio::test] +async fn budget_closed_period_and_isolation() { + let app = TestApp::new().await; + let a = app.register("A", None).await; + let b = app.register("B", None).await; + let pid = open_december(&app, &a, 0).await; + let body = json!({ "needs": 1, "wants": 0, "culture": 0, "unexpected": 0 }); + + // B erişemez + let checks = [ + app.get(&format!("/periods/{pid}/summary"), &b).await.0, + app.get(&format!("/periods/{pid}/budget"), &b).await.0, + app.put(&format!("/periods/{pid}/budget"), &b, body.clone()) + .await + .0, + app.put( + &format!("/periods/{pid}/budget/weeks"), + &b, + json!({ "group": "needs", "week_start": "2026-12-07", "amount": 1 }), + ) + .await + .0, + app.get(&format!("/periods/{pid}/budget/suggestion"), &b) + .await + .0, + ]; + assert!( + checks.iter().all(|s| *s == StatusCode::NOT_FOUND), + "{checks:?}" + ); + + // Kapalı dönem: okuma serbest, yazma 409 + let id = Uuid::parse_str(&pid).unwrap(); + app.db + .collection::("periods") + .update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } }) + .await + .unwrap(); + assert_eq!( + app.get(&format!("/periods/{pid}/budget"), &a).await.0, + StatusCode::OK + ); + assert_eq!( + app.put(&format!("/periods/{pid}/budget"), &a, body).await.0, + StatusCode::CONFLICT + ); + + app.cleanup().await; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 276009f..ec3375e 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**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ı. +**Faz 4 – Adım 4:** Frontend ilk dönem sihirbazı: açık dönem yoksa (Panel/Plan) → adımlar: 1) tarihler (varsayılan: içinde bulunulan ay, ya da önceki dönemin bitişi), 2) sabit gelirler, 3) sabit giderler (+ önceki dönemden kopyala varsa), 4) birikim hedefi, 5) bütçe dağıtımı (öneriyle) + özet. `lib/format.ts` parseMoney ile tutar girişi (inputMode=decimal). TanStack Query anahtarları: period/current, plan-items, summary, budget. ## 📌 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`). @@ -40,6 +40,9 @@ - ts-rs: `i64` tutarlar `#[ts(type = "number")]`; isteklerde `Option` 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ü). +- Bütçe: aylık = `budgets{scope:"month"}`; haftalık otomatik = (aylık − elle girilenler) gün oranında, `money::split_proportional` (en büyük kalan, toplam tam eşit). Elle hafta = `scope:"week", week_start`; `null` → soft delete. Aylık değişince elle haftalar korunur; elle toplam > aylık → `weekly_over`. +- Özet: spendable = gelir − sabit gider − birikim − borç + artı (`carryovers.to_period_id`). `unallocated` = spendable − bütçe toplamı (negatif = fazla dağıtım). +- Öneri: default = hane `settings.budget_split_pct` × spendable (≤0 → available:false); previous/avg3 = önceki kapalı dönem(ler)in `entries` toplamı / dönem sayısı (yuvarlayarak). - 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 @@ -87,3 +90,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. +- **Adım 3:** `services/money.rs` (`split_proportional` + 3 test; beklenen değer hatası testteydi, elle hesapla düzeltildi), `services/budget.rs` (summary, view, set_monthly, set_week, suggest), `routes/budget.rs` (5 uç nokta, 9 TS tipi). `tests/budget.rs` (6) — özet formülü bilerek bozulunca yakaladı. Backend 88 test. diff --git a/docs/PHASES.md b/docs/PHASES.md index 21f9d1c..acfe183 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -48,7 +48,7 @@ ## 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) -- ⏳ 4. Frontend: ilk dönem sihirbazı +- ✅ 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) diff --git a/frontend/src/types/BudgetSuggestion.ts b/frontend/src/types/BudgetSuggestion.ts new file mode 100644 index 0000000..f623871 --- /dev/null +++ b/frontend/src/types/BudgetSuggestion.ts @@ -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 { GroupAmounts } from "./GroupAmounts"; + +export type BudgetSuggestion = { +/** + * `false` → bu yöntem için veri yok (`message` nedenini söyler) + */ +available: boolean, message: string | null, amounts: GroupAmounts, }; diff --git a/frontend/src/types/BudgetWeekRequest.ts b/frontend/src/types/BudgetWeekRequest.ts new file mode 100644 index 0000000..08607b3 --- /dev/null +++ b/frontend/src/types/BudgetWeekRequest.ts @@ -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 { Group } from "./Group"; + +export type BudgetWeekRequest = { group: Group, +/** + * Haftanın ilk günü (`PeriodWeek.start_date`) + */ +week_start: string, +/** + * Kuruş; `null` → otomatiğe döndür + */ +amount: number | null, }; diff --git a/frontend/src/types/GroupAmounts.ts b/frontend/src/types/GroupAmounts.ts new file mode 100644 index 0000000..5b85198 --- /dev/null +++ b/frontend/src/types/GroupAmounts.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Gruplara göre tutarlar (kuruş) + */ +export type GroupAmounts = { needs: number, wants: number, culture: number, unexpected: number, }; diff --git a/frontend/src/types/GroupBudget.ts b/frontend/src/types/GroupBudget.ts new file mode 100644 index 0000000..0214404 --- /dev/null +++ b/frontend/src/types/GroupBudget.ts @@ -0,0 +1,9 @@ +// 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"; +import type { WeekBudget } from "./WeekBudget"; + +export type GroupBudget = { group: Group, monthly: number, +/** + * Elle girilen haftaların toplamı aylık tutarı aşıyor + */ +weekly_over: boolean, weeks: Array, }; diff --git a/frontend/src/types/PeriodSummary.ts b/frontend/src/types/PeriodSummary.ts new file mode 100644 index 0000000..301054e --- /dev/null +++ b/frontend/src/types/PeriodSummary.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PeriodSummary = { income_total: number, fixed_expense_total: number, savings_goal: number, +/** + * Önceki dönemden devreden borç + */ +carryover_debt: number, +/** + * Önceki dönemden devreden artı bakiye + */ +carryover_surplus: number, +/** + * gelir − sabit gider − birikim − borç + artı + */ +spendable: number, +/** + * 4 grubun aylık bütçe toplamı + */ +budget_total: number, +/** + * harcanabilir − dağıtılan (negatifse fazla dağıtılmış) + */ +unallocated: number, }; diff --git a/frontend/src/types/SuggestMethod.ts b/frontend/src/types/SuggestMethod.ts new file mode 100644 index 0000000..5f04091 --- /dev/null +++ b/frontend/src/types/SuggestMethod.ts @@ -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 SuggestMethod = "default" | "previous" | "avg3"; diff --git a/frontend/src/types/WeekBudget.ts b/frontend/src/types/WeekBudget.ts new file mode 100644 index 0000000..9fe5e0a --- /dev/null +++ b/frontend/src/types/WeekBudget.ts @@ -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 WeekBudget = { index: number, start_date: string, last_date: string, days: number, amount: number, +/** + * Elle girildi (otomatik değil) + */ +is_manual: boolean, };