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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user