Compare commits
2 Commits
b883530f76
...
95231cd4d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 95231cd4d6 | |||
| 7f3fde074c |
@@ -170,3 +170,96 @@ 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
|
||||
|
||||
# ─────────────────────────── 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<budget::GroupAmounts> for GroupAmounts {
|
||||
fn from(a: budget::GroupAmounts) -> Self {
|
||||
Self {
|
||||
needs: a[0],
|
||||
wants: a[1],
|
||||
culture: a[2],
|
||||
unexpected: a[3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GroupAmounts> 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<WeekBudgetDto>,
|
||||
}
|
||||
|
||||
#[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<GroupBudget> 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<i64>,
|
||||
}
|
||||
|
||||
#[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<Method>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
amounts: GroupAmounts,
|
||||
}
|
||||
|
||||
fn parse_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn summary(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<SummaryDto>> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<GroupAmounts>,
|
||||
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<WeekRequest>,
|
||||
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Query(q): Query<SuggestQuery>,
|
||||
) -> AppResult<Json<SuggestionDto>> {
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
//! 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;
|
||||
mod note;
|
||||
mod period;
|
||||
mod plan;
|
||||
mod spa;
|
||||
|
||||
use axum::{
|
||||
@@ -63,6 +66,24 @@ 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("/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 });
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
//! İş 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;
|
||||
pub mod seed;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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<i64> {
|
||||
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::<Document>("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::<i64>(),
|
||||
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::<i64>(), 100_000);
|
||||
let manual: Vec<bool> = 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::<i64>(), 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::<i64>(), 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::<Document>("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::<Document>("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::<Document>("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;
|
||||
}
|
||||
@@ -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(¬e_path, &s, json!({ "title": "Y" })).await.0,
|
||||
app.delete(¬e_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(¬es, &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(¬es, &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(¬es, &s).await;
|
||||
assert_eq!(all.as_array().unwrap().len(), 2);
|
||||
|
||||
let (st, _) = app
|
||||
.post(¬es, &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(¬e_path, &b, json!({ "title": "X" })).await.0,
|
||||
app.delete(¬e_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;
|
||||
}
|
||||
+9
-2
@@ -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 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`).
|
||||
@@ -38,10 +38,15 @@
|
||||
- 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ü).
|
||||
- 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
|
||||
- **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 +89,5 @@
|
||||
|
||||
### 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.
|
||||
|
||||
+3
-3
@@ -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)
|
||||
- ⏳ 4. Frontend: ilk dönem sihirbazı
|
||||
- ✅ 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)
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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<WeekBudget>, };
|
||||
@@ -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, };
|
||||
@@ -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";
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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";
|
||||
@@ -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, };
|
||||
Reference in New Issue
Block a user