feat(budget): aylık bütçe motoru, limitler ve ay sonu tahmini

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 00:58:51 +03:00
parent 6de6d07c66
commit 3fa1e949e2
10 changed files with 1179 additions and 1 deletions
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
budgetPeriod,
computeBudget,
daysBetween,
forecast,
periodOfDate,
roundUpLimit,
shiftMonth,
statusOf,
type CategoryAmounts,
} from "./budget";
describe("bütçe dönemi", () => {
it("takvim ayı ve 15'inde başlayan ay", () => {
expect(budgetPeriod("2026-11")).toEqual({ from: "2026-11-01", to: "2026-11-30" });
expect(budgetPeriod("2026-12", 15)).toEqual({ from: "2026-12-15", to: "2027-01-14" });
expect(budgetPeriod("2027-01", 28)).toEqual({ from: "2027-01-28", to: "2027-02-27" });
expect(periodOfDate("2026-11-14", 15)).toBe("2026-10");
expect(periodOfDate("2026-11-15", 15)).toBe("2026-11");
expect(periodOfDate("2027-01-03", 15)).toBe("2026-12");
expect(periodOfDate("2026-11-30")).toBe("2026-11");
expect(shiftMonth("2026-12", 1)).toBe("2027-01");
expect(shiftMonth("2026-01", -1)).toBe("2025-12");
expect(daysBetween("2026-11-01", "2026-11-30")).toBe(30);
expect(daysBetween("2026-12-15", "2027-01-14")).toBe(31);
});
it("durum eşikleri %80 ve %100", () => {
expect(statusOf(79_99, 100_00)).toBe("ok");
expect(statusOf(80_00, 100_00)).toBe("warning");
expect(statusOf(100_00, 100_00)).toBe("over");
expect(statusOf(5, null)).toBe("none");
expect(statusOf(1, 0)).toBe("over");
});
it("ay sonu tahmini: sabit + planlı + değişken tempo", () => {
const a: CategoryAmounts = {
fixedMinor: 1_000_00,
variableMinor: 3_000_00,
plannedMinor: 500_00,
};
// 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);
expect(roundUpLimit(1_234_56)).toBe(1_300_00);
expect(roundUpLimit(1_200_00)).toBe(1_200_00);
});
});
describe("bütçe toplama", () => {
const categories = [
{ id: "market", parentId: null, name: "Market", order: 1 },
{ id: "faturalar", parentId: null, name: "Faturalar", order: 2 },
{ id: "elektrik", parentId: "faturalar", name: "Elektrik", order: 1 },
{ id: "su", parentId: "faturalar", name: "Su", order: 2 },
{ id: "eglence", parentId: null, name: "Eğlence", order: 3 },
{ id: "sinema", parentId: "eglence", name: "Sinema", order: 1 },
{ id: "giyim", parentId: null, name: "Giyim", order: 4 },
];
const amt = (variable: number, fixed = 0, planned = 0): CategoryAmounts => ({
fixedMinor: fixed,
variableMinor: variable,
plannedMinor: planned,
});
it("üst limit alt kategorileri kapsar; alt limit iki kez sayılmaz; bütçesiz harcama", () => {
const r = computeBudget({
categories,
limits: new Map([
["market", 10_000_00],
["faturalar", 3_000_00],
["elektrik", 1_500_00],
["sinema", 500_00],
]),
amounts: new Map([
["market", amt(8_500_00)],
["elektrik", amt(0, 1_600_00)],
["su", amt(0, 400_00, 300_00)],
["faturalar", amt(100_00)],
["sinema", amt(200_00)],
["eglence", amt(300_00)],
["giyim", amt(1_000_00)],
]),
elapsedDays: 30,
totalDays: 30,
});
// Toplam limit: market + faturalar + sinema (elektrik faturaların içinde).
expect(r.totals.limitMinor).toBe(13_500_00);
expect(r.totals.actualMinor).toBe(12_100_00);
// Bütçesiz: eğlencenin kendi harcaması + giyim.
expect(r.totals.unbudgetedMinor).toBe(1_300_00);
const line = (id: string) => r.lines.find((l) => l.categoryId === id)!;
expect(line("faturalar")).toMatchObject({
actualMinor: 2_100_00,
plannedMinor: 300_00,
status: "ok",
ratioBp: 7000,
});
expect(line("elektrik")).toMatchObject({ status: "over", remainingMinor: -100_00 });
expect(line("market").status).toBe("warning");
expect(line("eglence")).toMatchObject({
limitMinor: null,
actualMinor: 500_00,
status: "none",
});
// Sıra: ağaç sırası, derinlik.
expect(r.lines.map((l) => `${l.depth}:${l.categoryId}`)).toEqual([
"0:market",
"0:faturalar",
"1:elektrik",
"1:su",
"0:eglence",
"1:sinema",
"0:giyim",
]);
});
it("tahmin limiti aşıyor uyarısı; limit yoksa durum none", () => {
const r = computeBudget({
categories,
limits: new Map([["market", 10_000_00]]),
amounts: new Map([["market", amt(4_000_00)]]),
elapsedDays: 10,
totalDays: 30,
});
expect(r.totals).toMatchObject({ status: "ok", forecastMinor: 12_000_00, forecastOver: true });
const empty = computeBudget({
categories,
limits: new Map(),
amounts: new Map(),
elapsedDays: 1,
totalDays: 30,
});
expect(empty.totals.status).toBe("none");
expect(empty.lines).toHaveLength(0);
});
});