import { addDays, addMonths } from "./recurrence"; /** * Aylık bütçe hesap motoru (Bölüm 6.9). Tutarlar kuruş, tarihler İstanbul günü "YYYY-MM-DD". * * Bütçe ayı hane ayarı `budgetMonthStartDay` ile başlar (Faz 0): başlangıç günü 15 ise * "2026-11" dönemi 15 Kasım – 14 Aralık'tır; 1 ise takvim ayıdır. * * Limitler (Faz 10 kararı): her gider kategorisine verilebilir. Üst kategori limiti alt * kategorilerin harcamasını da kapsar; alt kategori limiti üstün içinde bir alt sınırdır ve * toplam bütçede iki kez sayılmaz. */ export const WARNING_BP = 8_000; export const OVER_BP = 10_000; /** "YYYY-MM" dönemi: ilk ve son gün (ikisi de dahil). */ export function budgetPeriod(month: string, startDay = 1): { from: string; to: string } { const from = `${month}-${String(startDay).padStart(2, "0")}`; const to = addDays(addMonths(from, 1, startDay), -1); return { from, to }; } /** Tarihin düştüğü bütçe ayı. */ export function periodOfDate(date: string, startDay = 1): string { const day = Number(date.slice(8, 10)); const month = date.slice(0, 7); return day >= startDay ? month : addMonths(`${month}-01`, -1).slice(0, 7); } export function shiftMonth(month: string, n: number): string { return addMonths(`${month}-01`, n).slice(0, 7); } /** İki gün arasındaki gün sayısı (ikisi de dahil). */ export function daysBetween(from: string, to: string): number { const ms = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`); return Math.round(ms / 86_400_000) + 1; } export type BudgetStatus = "none" | "ok" | "warning" | "over"; export function statusOf(actualMinor: number, limitMinor: number | null): BudgetStatus { if (limitMinor === null) return "none"; if (limitMinor <= 0) return actualMinor > 0 ? "over" : "ok"; const bp = (actualMinor * 10_000) / limitMinor; return bp >= OVER_BP ? "over" : bp >= WARNING_BP ? "warning" : "ok"; } export type BudgetCategoryInput = { id: string; parentId: string | null; name: string; order: number; }; /** Kategori bazında kendi (alt kategoriler hariç) tutarlar. */ export type CategoryAmounts = { /** Düzenli işlem veya taksitten gelen gerçekleşmiş gider (tempo tahminine katılmaz). */ fixedMinor: number; /** Diğer gerçekleşmiş gider. */ 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 = { categoryId: string; name: string; depth: number; limitMinor: number | null; /** Kendi limiti yok ama bir üst kategorinin limiti kapsıyor. */ covered: boolean; /** Alt kategoriler dahil gerçekleşen. */ actualMinor: number; plannedMinor: number; forecastMinor: number; remainingMinor: number | null; ratioBp: number | null; status: BudgetStatus; /** Tahmin limiti aşıyor (henüz aşılmamış olsa da). */ forecastOver: boolean; }; export type BudgetTotals = { limitMinor: number; actualMinor: number; plannedMinor: number; forecastMinor: number; remainingMinor: number; /** Hiçbir limitin kapsamadığı gerçekleşen gider. */ unbudgetedMinor: number; ratioBp: number | null; status: BudgetStatus; forecastOver: boolean; }; /** * 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, 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: { categories: BudgetCategoryInput[]; limits: Map; 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, baselineDays } = input; const byId = new Map(categories.map((c) => [c.id, c])); const children = new Map(); const roots: BudgetCategoryInput[] = []; for (const c of categories) { if (c.parentId && byId.has(c.parentId)) { const list = children.get(c.parentId) ?? []; list.push(c); children.set(c.parentId, list); } else roots.push(c); } 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, baselineMinor: 0, }; // Alt ağaç toplamları. const subtree = new Map(); const sumOf = (c: BudgetCategoryInput, seen: Set): CategoryAmounts => { if (seen.has(c.id)) return zero; seen.add(c.id); const own = amounts.get(c.id) ?? zero; 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; }; const seen = new Set(); for (const r of roots) sumOf(r, seen); const lines: BudgetLine[] = []; let limitTotal = 0; let unbudgeted = 0; const walk = (list: BudgetCategoryInput[], depth: number, covered: boolean) => { for (const c of sort(list)) { const limit = limits.get(c.id) ?? null; const s = subtree.get(c.id) ?? zero; const own = amounts.get(c.id) ?? zero; if (limit !== null && !covered) limitTotal += limit; const isCovered = covered || limit !== null; if (!isCovered) unbudgeted += own.fixedMinor + own.variableMinor; const actual = s.fixedMinor + s.variableMinor; const fc = forecast(s, elapsedDays, totalDays, baselineDays); if (limit !== null || actual > 0 || s.plannedMinor > 0) { lines.push({ categoryId: c.id, name: c.name, depth, limitMinor: limit, covered: limit === null && covered, actualMinor: actual, plannedMinor: s.plannedMinor, forecastMinor: fc, remainingMinor: limit === null ? null : limit - actual, ratioBp: limit && limit > 0 ? Math.round((actual * 10_000) / limit) : null, status: statusOf(actual, limit), forecastOver: limit !== null && fc > limit, }); } walk(children.get(c.id) ?? [], depth + 1, isCovered); } }; walk(roots, 0, false); let actual = 0; let planned = 0; let fc = 0; for (const r of roots) { const s = subtree.get(r.id) ?? zero; actual += s.fixedMinor + s.variableMinor; planned += s.plannedMinor; fc += forecast(s, elapsedDays, totalDays, baselineDays); } const hasLimit = limits.size > 0; return { lines, totals: { limitMinor: limitTotal, actualMinor: actual, plannedMinor: planned, forecastMinor: fc, remainingMinor: limitTotal - actual, unbudgetedMinor: unbudgeted, ratioBp: limitTotal > 0 ? Math.round((actual * 10_000) / limitTotal) : null, status: hasLimit ? statusOf(actual, limitTotal) : "none", forecastOver: hasLimit && fc > limitTotal, }, }; } /** Limit önerisi için yukarı yuvarlama (100 TL'lik adım). */ export function roundUpLimit(minor: number, stepMinor = 100_00): number { return Math.ceil(minor / stepMinor) * stepMinor; }