feat(bütçe): aylık/haftalık bütçe, bütçe önerisi ve dönem özeti (backend)

- /api/periods/{id}/summary: gelir − sabit gider − birikim − devreden borç + artı = harcanabilir
- /api/periods/{id}/budget: 4 grup aylık; haftalar gün sayısına göre, toplam kuruşu kuruşuna eşit
- Elle hafta ayarı (kalan diğer haftalara dağılır), otomatiğe dönüş, aşım uyarısı
- Öneri: varsayılan yüzdeler / önceki dönem / son 3 dönem ortalaması
- Testler: orantılı bölme (3), bütçe entegrasyon (6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 03:42:49 +03:00
parent 7f3fde074c
commit 95231cd4d6
16 changed files with 1337 additions and 3 deletions
+502
View File
@@ -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<WeekBudget>,
/// 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<String>,
pub amounts: GroupAmounts,
}
#[derive(Deserialize)]
struct BudgetDoc {
group: Group,
scope: String,
#[serde(default)]
week_start: Option<String>,
amount: i64,
}
// ---------- Özet ----------
pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Summary> {
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<Vec<GroupBudget>> {
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<String, i64> = 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<u64> = 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<i64>,
) -> 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::<Document>(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<Suggestion> {
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<Bson> = 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<Vec<BudgetDoc>> {
let mut f = scope(household_id);
f.insert("period_id", period_id);
let mut cursor = db.collection::<BudgetDoc>(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<GroupAmounts> {
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::<Document>(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<HashMap<String, i64>> {
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::<Document>(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<Vec<u64>> {
let mut f = scope(household_id);
f.insert("_id", household_id);
let hh = db
.collection::<Document>(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<Vec<Uuid>> {
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::<Period>(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)
}
+2
View File
@@ -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;
+68
View File
@@ -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<i64> {
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<i64> = 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::<i64>();
// 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::<i64>(), 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::<i64>::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::<i64>(), big);
}
}