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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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 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ı.
|
||||
*/
|
||||
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 computeBudget(input: {
|
||||
categories: BudgetCategoryInput[];
|
||||
limits: Map<string, number>;
|
||||
amounts: Map<string, CategoryAmounts>;
|
||||
elapsedDays: number;
|
||||
totalDays: number;
|
||||
}): { lines: BudgetLine[]; totals: BudgetTotals } {
|
||||
const { categories, limits, amounts, elapsedDays, totalDays } = input;
|
||||
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||
const children = new Map<string, BudgetCategoryInput[]>();
|
||||
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 };
|
||||
|
||||
// Alt ağaç toplamları.
|
||||
const subtree = new Map<string, CategoryAmounts>();
|
||||
const sumOf = (c: BudgetCategoryInput, seen: Set<string>): CategoryAmounts => {
|
||||
if (seen.has(c.id)) return zero;
|
||||
seen.add(c.id);
|
||||
const own = amounts.get(c.id) ?? zero;
|
||||
const total = { ...own };
|
||||
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;
|
||||
}
|
||||
subtree.set(c.id, total);
|
||||
return total;
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
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);
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { nonNegativeMoneySchema } from "./account";
|
||||
|
||||
/** Bütçe ayı "YYYY-MM". */
|
||||
export const budgetMonthSchema = z
|
||||
.string()
|
||||
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, tr.budget.validation.month);
|
||||
|
||||
/**
|
||||
* Limit formu: kategori → tutar metni. Boş veya 0 limit kaldırılır (kategori bütçesiz olur).
|
||||
*/
|
||||
export const budgetLimitsSchema = z.object({
|
||||
month: budgetMonthSchema,
|
||||
limits: z
|
||||
.array(
|
||||
z.object({
|
||||
categoryId: z.string().regex(/^[a-f0-9]{24}$/i, tr.budget.validation.category),
|
||||
amount: nonNegativeMoneySchema,
|
||||
}),
|
||||
)
|
||||
.max(500)
|
||||
.transform((list) =>
|
||||
list
|
||||
.filter((l) => l.amount > 0)
|
||||
.map((l) => ({ categoryId: l.categoryId, amountMinor: l.amount })),
|
||||
),
|
||||
});
|
||||
export type BudgetLimitsForm = z.output<typeof budgetLimitsSchema>;
|
||||
Reference in New Issue
Block a user