diff --git a/src/features/budgets/service.int.test.ts b/src/features/budgets/service.int.test.ts index 13ecfe4..f0b25b2 100644 --- a/src/features/budgets/service.int.test.ts +++ b/src/features/budgets/service.int.test.ts @@ -206,6 +206,30 @@ describe("aylık bütçe", () => { expect((await line("aidat", shiftMonth(M, 1)))!.actualMinor).toBe(1_000_00); }); + it("tahmin: değişken gider tempoyla, sabit gider olduğu gibi (gelecek ayda değişken sayılmaz)", async () => { + const F = shiftMonth(todayIstanbul().slice(0, 7), 24); + const f = budgetPeriod(F); + await expense({ date: f.from, amountMinor: 900_00, category: cat.keyif }); + await expense({ date: f.from, amountMinor: 4_000_00, category: cat.kira, recurring: true }); + const keyif = (await line("keyif", F))!; + expect(keyif.actualMinor).toBe(900_00); + // Henüz başlamamış dönem, geçmiş yok: gerçekleşen değişken olduğu gibi, tempo eklenmez. + expect(keyif.forecastMinor).toBe(900_00); + expect((await line("kira", F))!.forecastMinor).toBe(4_000_00); + }); + + it("içinde bulunulan ayda değişken gider tempoyla büyür, sabit gider büyümez", async () => { + const today = todayIstanbul(); + const cur = today.slice(0, 7); + if (budgetPeriod(cur).to === today) return; // ayın son günü: kalan gün yok + await expense({ date: today, amountMinor: 1_000_00, category: cat.keyif }); + await expense({ date: today, amountMinor: 2_000_00, category: cat.kira, recurring: true }); + const keyif = (await line("keyif", cur))!; + expect(keyif.actualMinor).toBe(1_000_00); + expect(keyif.forecastMinor).toBeGreaterThan(1_000_00); + expect((await line("kira", cur))!.forecastMinor).toBe(2_000_00); + }); + it("bütçe ayı başlangıç günü dönemi kaydırır", async () => { await HouseholdModel.updateOne( { _id: cakir }, diff --git a/src/features/budgets/service.ts b/src/features/budgets/service.ts index f15905f..b22336a 100644 --- a/src/features/budgets/service.ts +++ b/src/features/budgets/service.ts @@ -34,18 +34,20 @@ import type { BudgetLimitsForm } from "@/shared/schemas/budget"; const t = tr.budget; const DAY_MS = 86_400_000; -type Settings = { +export type Settings = { startDay: number; installmentMode: "per_installment" | "purchase_month"; includePrivate: boolean; + dailyMode: "even" | "previous_month_pattern"; }; -async function settingsOf(householdId: Types.ObjectId): Promise { +export async function settingsOf(householdId: Types.ObjectId): Promise { const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean(); return { startDay: h?.settings?.budgetMonthStartDay ?? 1, installmentMode: h?.settings?.installmentBudgetMode ?? "per_installment", includePrivate: h?.settings?.includePrivateAccountsInTotals ?? true, + dailyMode: h?.settings?.dailyPlanMode ?? "even", }; } @@ -70,7 +72,10 @@ export async function expenseAmounts( $project: { status: 1, fixed: { - $or: [{ $ne: ["$recurringRuleId", null] }, { $ne: ["$installment.planId", null] }], + $or: [ + { $ne: [{ $ifNull: ["$recurringRuleId", null] }, null] }, + { $ne: [{ $ifNull: ["$installment.planId", null] }, null] }, + ], }, items: { $cond: [ @@ -205,17 +210,32 @@ export async function getBudget( expenseAmounts(ctx, s, period.from, period.to), ]); const limits = new Map((doc?.limits ?? []).map((l) => [String(l.categoryId), l.amountMinor])); + // Taban hız (bitmemiş aylar için): önceki dönemin değişken gideri, kategori bazında. + let baselineDays: number | undefined; + if (elapsedDays < totalDays) { + const prev = budgetPeriod(shiftMonth(m, -1), s.startDay); + const prevAmounts = await expenseAmounts(ctx, s, prev.from, prev.to); + let any = 0; + for (const [id, a] of prevAmounts) { + if (a.variableMinor <= 0) continue; + any += a.variableMinor; + const cur = amounts.get(id) ?? { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 }; + amounts.set(id, { ...cur, baselineMinor: a.variableMinor }); + } + if (any > 0) baselineDays = daysBetween(prev.from, prev.to); + } const cats = expenseCategories(ctx); // Arşivli kategori yalnızca limiti veya harcaması varsa görünür. const visibleCats = cats.filter((c) => !c.archived || limits.has(c.id) || amounts.has(c.id)); const known = new Set(visibleCats.map((c) => c.id)); // Kategorisiz / bilinmeyen kategorideki gider "Kategorisiz" satırında toplanır. - const orphan = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 }; + const orphan = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0, baselineMinor: 0 }; for (const [id, a] of amounts) if (!known.has(id)) { orphan.fixedMinor += a.fixedMinor; orphan.variableMinor += a.variableMinor; orphan.plannedMinor += a.plannedMinor; + orphan.baselineMinor += a.baselineMinor ?? 0; } const ORPHAN = "__none__"; const inputCats = visibleCats.map((c) => ({ @@ -234,6 +254,7 @@ export async function getBudget( amounts, elapsedDays, totalDays, + baselineDays, }); const editorCats = cats.filter((c) => !c.archived); // Düzenleyici için ağaç sırası ve derinlik (tüm etkin kategoriler, limit 0 ile). diff --git a/src/shared/budget.test.ts b/src/shared/budget.test.ts index 9f17e2d..30da869 100644 --- a/src/shared/budget.test.ts +++ b/src/shared/budget.test.ts @@ -40,13 +40,31 @@ describe("bütçe dönemi", () => { variableMinor: 3_000_00, plannedMinor: 500_00, }; - // 10/30 gün geçti: değişken 3.000 → 9.000 + // Geçmiş yok: 10/30 gün geçti, değişken 3.000 → 9.000 expect(forecast(a, 10, 30)).toBe(10_500_00); expect(forecast(a, 30, 30)).toBe(4_500_00); - expect(forecast(a, 0, 30)).toBe(1_500_00); + // Henüz başlamamış dönem: gerçekleşmiş değişken olduğu gibi, tempo yok. + expect(forecast(a, 0, 30)).toBe(4_500_00); expect(roundUpLimit(1_234_56)).toBe(1_300_00); expect(roundUpLimit(1_200_00)).toBe(1_200_00); }); + + it("taban hız: önceki ayın temposu ay başında ağır basar", () => { + // Ayın 2'sinde 6.000'lik tek harcama; önceki ay 30 günde 9.000 (günde 300). + const a: CategoryAmounts = { + fixedMinor: 0, + variableMinor: 6_000_00, + plannedMinor: 0, + baselineMinor: 9_000_00, + }; + // Yalnızca bu ay: 6.000 × 30/2 = 90.000. + expect(forecast(a, 2, 30)).toBe(90_000_00); + // Karışım: hız = 2/30 × 3.000 + 28/30 × 300 = 480 → 6.000 + 28 × 480 + expect(forecast(a, 2, 30, 30)).toBe(6_000_00 + 28 * 480_00); + // Gelecek ay: yalnızca taban; geçmiş ay: gerçekleşen. + expect(forecast({ ...a, variableMinor: 0 }, 0, 30, 30)).toBe(9_000_00); + expect(forecast(a, 30, 30, 30)).toBe(6_000_00); + }); }); describe("bütçe toplama", () => { diff --git a/src/shared/budget.ts b/src/shared/budget.ts index b0bd5ac..1bc9c65 100644 --- a/src/shared/budget.ts +++ b/src/shared/budget.ts @@ -62,6 +62,8 @@ export type CategoryAmounts = { variableMinor: number; /** Dönemin kalanındaki planlı (henüz gerçekleşmemiş) gider. */ plannedMinor: number; + /** Önceki dönemin değişken gideri (tahmin için taban hız; yoksa 0). */ + baselineMinor?: number; }; export type BudgetLine = { @@ -96,18 +98,28 @@ export type BudgetTotals = { }; /** - * Ay sonu tahmini: sabit giderler olduğu gibi + kalan planlı giderler + değişken giderin - * şimdiye kadarki günlük hızıyla ay sonuna yansıtılması. Geçmiş ayda tahmin = gerçekleşen; - * gelecek ayda yalnızca planlı. + * Ay sonu tahmini: sabit giderler olduğu gibi + kalan planlı giderler + değişken gider ve kalan + * günler için günlük hız tahmini. + * + * Günlük hız, bu ayın hızı ile önceki dönemin değişken harcama hızının (taban) karışımıdır; ay + * ilerledikçe bu ayın ağırlığı artar (geçen gün / toplam gün). Böylece ay başındaki tek bir büyük + * harcama tüm aya yayılmaz. Taban yoksa (`baselineDays` verilmemiş: hanenin geçmişi yok) yalnızca + * bu ayın hızı kullanılır. Geçmiş ayda tahmin = gerçekleşen. */ -export function forecast(a: CategoryAmounts, elapsedDays: number, totalDays: number): number { - const variable = - elapsedDays <= 0 - ? 0 - : elapsedDays >= totalDays - ? a.variableMinor - : Math.round((a.variableMinor * totalDays) / elapsedDays); - return a.fixedMinor + a.plannedMinor + variable; +export function forecast( + a: CategoryAmounts, + elapsedDays: number, + totalDays: number, + baselineDays?: number, +): number { + const remaining = Math.max(0, totalDays - Math.max(0, elapsedDays)); + const current = elapsedDays > 0 ? a.variableMinor / elapsedDays : 0; + let pace = current; + if (baselineDays && baselineDays > 0) { + const w = Math.min(1, Math.max(0, elapsedDays) / totalDays); + pace = w * current + (1 - w) * ((a.baselineMinor ?? 0) / baselineDays); + } + return a.fixedMinor + a.plannedMinor + a.variableMinor + Math.round(pace * remaining); } export function computeBudget(input: { @@ -116,8 +128,10 @@ export function computeBudget(input: { amounts: Map; elapsedDays: number; totalDays: number; + /** Önceki dönemin gün sayısı (taban hız için); hanenin geçmişi yoksa verilmez. */ + baselineDays?: number; }): { lines: BudgetLine[]; totals: BudgetTotals } { - const { categories, limits, amounts, elapsedDays, totalDays } = input; + const { categories, limits, amounts, elapsedDays, totalDays, baselineDays } = input; const byId = new Map(categories.map((c) => [c.id, c])); const children = new Map(); const roots: BudgetCategoryInput[] = []; @@ -130,7 +144,12 @@ export function computeBudget(input: { } const sort = (list: BudgetCategoryInput[]) => list.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr")); - const zero: CategoryAmounts = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 }; + const zero: CategoryAmounts = { + fixedMinor: 0, + variableMinor: 0, + plannedMinor: 0, + baselineMinor: 0, + }; // Alt ağaç toplamları. const subtree = new Map(); @@ -138,12 +157,13 @@ export function computeBudget(input: { if (seen.has(c.id)) return zero; seen.add(c.id); const own = amounts.get(c.id) ?? zero; - const total = { ...own }; + const total = { ...own, baselineMinor: own.baselineMinor ?? 0 }; for (const k of children.get(c.id) ?? []) { const s = sumOf(k, seen); total.fixedMinor += s.fixedMinor; total.variableMinor += s.variableMinor; total.plannedMinor += s.plannedMinor; + total.baselineMinor += s.baselineMinor ?? 0; } subtree.set(c.id, total); return total; @@ -163,7 +183,7 @@ export function computeBudget(input: { const isCovered = covered || limit !== null; if (!isCovered) unbudgeted += own.fixedMinor + own.variableMinor; const actual = s.fixedMinor + s.variableMinor; - const fc = forecast(s, elapsedDays, totalDays); + const fc = forecast(s, elapsedDays, totalDays, baselineDays); if (limit !== null || actual > 0 || s.plannedMinor > 0) { lines.push({ categoryId: c.id, @@ -192,7 +212,7 @@ export function computeBudget(input: { const s = subtree.get(r.id) ?? zero; actual += s.fixedMinor + s.variableMinor; planned += s.plannedMinor; - fc += forecast(s, elapsedDays, totalDays); + fc += forecast(s, elapsedDays, totalDays, baselineDays); } const hasLimit = limits.size > 0; return {