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
+44
View File
@@ -0,0 +1,44 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { budgetLimitsSchema, budgetMonthSchema } from "@/shared/schemas/budget";
import { budgetFromActuals, copyPreviousBudget, saveBudget } from "./service";
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
async function run<T>(
schema: z.ZodType<T>,
input: unknown,
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
): Promise<ActionResult<unknown>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = schema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
export async function saveBudgetAction(input: unknown) {
return run(budgetLimitsSchema, input, saveBudget);
}
export async function copyPreviousBudgetAction(month: unknown) {
return run(budgetMonthSchema, month, copyPreviousBudget);
}
export async function budgetFromActualsAction(month: unknown) {
return run(budgetMonthSchema, month, budgetFromActuals);
}
+243
View File
@@ -0,0 +1,243 @@
import { Types } from "mongoose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createCategory } from "@/features/categories/service";
import { AccountModel } from "@/models/account";
import { BudgetModel } from "@/models/budget";
import { HouseholdModel } from "@/models/household";
import { TransactionModel } from "@/models/transaction";
import { budgetPeriod, shiftMonth } from "@/shared/budget";
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { budgetLimitsSchema } from "@/shared/schemas/budget";
import { categoryFormSchema } from "@/shared/schemas/category";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import { budgetFromActuals, copyPreviousBudget, getBudget, saveBudget } from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
// Seed verisinin olmadığı geçmiş bir ay: toplamlar yalnızca bu testin işlemlerinden oluşur.
const M = shiftMonth(todayIstanbul().slice(0, 7), -24);
const P = shiftMonth(M, -1);
const cat = { ev: "", kira: "", aidat: "", keyif: "" };
type CatKey = keyof typeof cat;
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
emre = ids.user("emre");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
const mk = async (key: CatKey, name: string, parent = "") => {
const r = await createCategory(
ayse,
cakir,
categoryFormSchema.parse({
kind: "expense",
name,
parentId: parent,
icon: "tag",
color: "blue",
}),
);
cat[key] = r.ok ? r.data.id : "";
};
await mk("ev", "Test Ev");
await mk("kira", "Test Kira", cat.ev);
await mk("aidat", "Test Aidat", cat.ev);
await mk("keyif", "Test Keyif");
});
afterAll(teardownDb);
const acc = (name: string) =>
AccountModel.findOne({ householdId: cakir, name })
.lean()
.then((a) => a!._id);
async function expense(input: {
date: string;
amountMinor: number;
category?: string;
splits?: { category: string; amountMinor: number }[];
account?: string;
status?: "posted" | "planned";
recurring?: boolean;
installment?: { planId: Types.ObjectId; index: number; count: number };
}) {
const actor = new Types.ObjectId(ayse);
const splits = (input.splits ?? []).map((s) => ({
categoryId: new Types.ObjectId(s.category),
amountMinor: s.amountMinor,
}));
await TransactionModel.create({
householdId: new Types.ObjectId(cakir),
type: "expense",
status: input.status ?? "posted",
date: istanbulDayStart(input.date)!,
accountId: await acc(input.account ?? "Cüzdan"),
amountMinor: input.amountMinor,
categoryId: input.category ? new Types.ObjectId(input.category) : null,
splits,
effectiveCategoryIds: input.category
? [new Types.ObjectId(input.category)]
: splits.map((s) => s.categoryId),
recurringRuleId: input.recurring ? new Types.ObjectId() : null,
occurrenceIndex: input.recurring ? 0 : null,
installment: input.installment ?? null,
createdBy: actor,
updatedBy: actor,
});
}
const limits = (month: string, list: Partial<Record<CatKey, string>>) =>
budgetLimitsSchema.parse({
month,
limits: Object.entries(list).map(([k, amount]) => ({ categoryId: cat[k as CatKey], amount })),
});
const line = async (key: CatKey, month = M, user = ayse) =>
(await getBudget(user, cakir, month))!.lines.find((l) => l.categoryId === cat[key]);
describe("aylık bütçe", () => {
const { from, to } = budgetPeriod(M);
it("limitler; üst limit alt kategorileri kapsar; bölünmüş işlem kalem bazında; planlı ayrı", async () => {
await expense({ date: from, amountMinor: 15_000_00, category: cat.kira, recurring: true });
await expense({
date: addDays(from, 5),
amountMinor: 3_000_00,
splits: [
{ category: cat.aidat, amountMinor: 1_200_00 },
{ category: cat.keyif, amountMinor: 1_800_00 },
],
});
await expense({ date: to, amountMinor: 500_00, category: cat.aidat, status: "planned" });
expect((await saveBudget(ayse, cakir, limits(M, { ev: "17.000", kira: "15.000" }))).ok).toBe(
true,
);
const b = (await getBudget(ayse, cakir, M))!;
expect(b.hasBudget).toBe(true);
// Geçmiş ay: tüm günler geçti.
expect(b.elapsedDays).toBe(b.totalDays);
expect(b.totals.limitMinor).toBe(17_000_00);
expect(b.totals.actualMinor).toBe(18_000_00);
expect(b.totals.unbudgetedMinor).toBe(1_800_00);
expect(await line("ev")).toMatchObject({
actualMinor: 16_200_00,
plannedMinor: 500_00,
forecastMinor: 16_700_00,
status: "warning",
forecastOver: false,
});
expect(await line("kira")).toMatchObject({ status: "over", remainingMinor: 0 });
expect(await line("keyif")).toMatchObject({ limitMinor: null, actualMinor: 1_800_00 });
// Boş limit kaldırılır.
await saveBudget(ayse, cakir, limits(M, { ev: "17.000", kira: "" }));
expect((await line("kira"))!.limitMinor).toBeNull();
});
it("gizli hesap harcaması hane ayarına göre", async () => {
const actor = new Types.ObjectId(emre);
const secret = await AccountModel.create({
householdId: new Types.ObjectId(cakir),
name: "Emre Gizli Test",
type: "cash",
openingBalanceMinor: 0,
openingDate: istanbulDayStart(addDays(from, -10))!,
isPrivate: true,
ownerUserId: actor,
createdBy: actor,
updatedBy: actor,
});
await expense({
date: addDays(from, 2),
amountMinor: 700_00,
category: cat.keyif,
account: "Emre Gizli Test",
});
expect((await line("keyif"))!.actualMinor).toBe(2_500_00);
const b = (await getBudget(ayse, cakir, M))!;
expect(b.includesHidden).toBe(true);
await HouseholdModel.updateOne(
{ _id: cakir },
{ $set: { "settings.includePrivateAccountsInTotals": false } },
);
expect((await line("keyif"))!.actualMinor).toBe(1_800_00);
// Sahibi her zaman görür.
expect((await line("keyif", M, emre))!.actualMinor).toBe(2_500_00);
await HouseholdModel.updateOne(
{ _id: cakir },
{ $set: { "settings.includePrivateAccountsInTotals": true } },
);
expect(secret).toBeTruthy();
});
it("taksit modu: her taksit kendi ayına veya tamamı alış ayına", async () => {
const planId = new Types.ObjectId();
const next = budgetPeriod(shiftMonth(M, 1));
await expense({
date: addDays(from, 10),
amountMinor: 1_000_00,
category: cat.aidat,
installment: { planId, index: 1, count: 2 },
});
await expense({
date: addDays(next.from, 10),
amountMinor: 1_000_00,
category: cat.aidat,
installment: { planId, index: 2, count: 2 },
status: "planned",
});
const before = (await line("aidat"))!.actualMinor;
await HouseholdModel.updateOne(
{ _id: cakir },
{ $set: { "settings.installmentBudgetMode": "purchase_month" } },
);
expect((await line("aidat"))!.actualMinor).toBe(before + 1_000_00);
// Tamamı alış ayında sayıldığı için sonraki ayda görünmez.
expect((await line("aidat", shiftMonth(M, 1)))?.actualMinor ?? 0).toBe(0);
await HouseholdModel.updateOne(
{ _id: cakir },
{ $set: { "settings.installmentBudgetMode": "per_installment" } },
);
// Her taksit kendi ayında (geçmiş ay olduğu için vadesi gelen taksit gerçekleşmiştir).
expect((await line("aidat", shiftMonth(M, 1)))!.actualMinor).toBe(1_000_00);
});
it("bütçe ayı başlangıç günü dönemi kaydırır", async () => {
await HouseholdModel.updateOne(
{ _id: cakir },
{ $set: { "settings.budgetMonthStartDay": 15 } },
);
const b = (await getBudget(ayse, cakir, M))!;
expect(b.period).toEqual(budgetPeriod(M, 15));
// Ayın 1'indeki kira artık önceki bütçe ayında.
expect((await line("kira"))?.actualMinor ?? 0).toBe(0);
await HouseholdModel.updateOne({ _id: cakir }, { $set: { "settings.budgetMonthStartDay": 1 } });
});
it("önceki aydan kopyala ve önceki ayın harcamalarından oluştur", async () => {
const N = shiftMonth(M, 1);
expect((await copyPreviousBudget(ayse, cakir, P)).ok).toBe(false);
expect((await copyPreviousBudget(ayse, cakir, N)).ok).toBe(true);
expect((await getBudget(ayse, cakir, N))!.editor.limits[cat.ev]).toBe(17_000_00);
// M'nin harcamaları: Ev kökü 16.200 + taksit 1.000 → 17.200; Keyif 2.500 (gizli dahil).
expect((await budgetFromActuals(ayse, cakir, N)).ok).toBe(true);
const l = (await BudgetModel.findOne({ householdId: cakir, month: N }).lean())!.limits;
const get = (k: CatKey) => l.find((x) => String(x.categoryId) === cat[k])?.amountMinor;
expect(get("ev")).toBe(17_200_00);
expect(get("keyif")).toBe(2_500_00);
expect(get("kira")).toBeUndefined();
expect((await budgetFromActuals(ayse, cakir, shiftMonth(M, -5))).ok).toBe(false);
});
it("yetki: izleyici yazamaz; başka hane göremez; geçersiz kategori reddedilir", async () => {
expect((await getBudget(zeynep, cakir, M))!.canWrite).toBe(false);
expect((await saveBudget(zeynep, cakir, limits(M, { ev: "1" }))).ok).toBe(false);
expect(await getBudget(fatma, cakir, M)).toBeNull();
const annemler = ids.household("Annemler");
expect((await saveBudget(fatma, annemler, limits(M, { ev: "1" }))).ok).toBe(false);
});
});
+392
View File
@@ -0,0 +1,392 @@
import "server-only";
import { Types, type PipelineStage } from "mongoose";
import { hasHouseholdRole } from "@/features/households/roles";
import { loadContext, type Ctx } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { BudgetModel } from "@/models/budget";
import { HouseholdModel } from "@/models/household";
import { TransactionModel } from "@/models/transaction";
import type { IconKey, Swatch } from "@/shared/appearance";
import {
budgetPeriod,
computeBudget,
daysBetween,
periodOfDate,
roundUpLimit,
shiftMonth,
type BudgetLine,
type BudgetTotals,
type CategoryAmounts,
} from "@/shared/budget";
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
import type { BudgetLimitsForm } from "@/shared/schemas/budget";
/**
* Aylık bütçe (Bölüm 6.9). Okuma: viewer; yazma: member.
*
* Gerçekleşen: dönemdeki gerçekleşmiş (posted) giderler; bölünmüş işlemler kalem bazında.
* Planlı: dönemdeki planlı giderler (düzenli işlem tekrarları, gelecek taksitler).
* Taksit modu (hane ayarı): her taksit kendi ayına (varsayılan) veya tamamı alış ayına.
* Başkasının "yalnızca ben" hesabından giderler hane ayarına göre (yalnızca tutar) sayılır.
*/
const t = tr.budget;
const DAY_MS = 86_400_000;
type Settings = {
startDay: number;
installmentMode: "per_installment" | "purchase_month";
includePrivate: boolean;
};
async function settingsOf(householdId: Types.ObjectId): Promise<Settings> {
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,
};
}
/** Kategori bazında (kendi) sabit/değişken gerçekleşen ve planlı gider. */
export async function expenseAmounts(
ctx: Ctx,
s: Settings,
from: string,
to: string,
): Promise<Map<string, CategoryAmounts>> {
const start = istanbulDayStart(from)!;
const end = new Date(istanbulDayStart(to)!.getTime() + DAY_MS);
const base: Record<string, unknown> = {
householdId: ctx.m.householdId,
type: "expense",
deletedAt: null,
};
if (!s.includePrivate && ctx.hiddenAccountIds.length)
base.accountId = { $nin: ctx.hiddenAccountIds };
const itemsStage: PipelineStage = {
$project: {
status: 1,
fixed: {
$or: [{ $ne: ["$recurringRuleId", null] }, { $ne: ["$installment.planId", null] }],
},
items: {
$cond: [
{ $gt: [{ $size: { $ifNull: ["$splits", []] } }, 0] },
"$splits",
[{ categoryId: "$categoryId", amountMinor: "$amountMinor" }],
],
},
},
};
const group: PipelineStage[] = [
itemsStage,
{ $unwind: "$items" },
{
$group: {
_id: { c: "$items.categoryId", s: "$status", f: "$fixed" },
sum: { $sum: "$items.amountMinor" },
},
},
];
const match: Record<string, unknown> = {
...base,
status: { $in: ["posted", "planned"] },
date: { $gte: start, $lt: end },
};
if (s.installmentMode === "purchase_month") match["installment.planId"] = { $in: [null] };
const rows = await TransactionModel.aggregate<{
_id: { c: Types.ObjectId | null; s: string; f: boolean };
sum: number;
}>([{ $match: match }, ...group]);
const out = new Map<string, CategoryAmounts>();
const add = (cat: string, k: keyof CategoryAmounts, v: number) => {
const cur = out.get(cat) ?? { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
cur[k] += v;
out.set(cat, cur);
};
for (const r of rows) {
const cat = r._id.c ? String(r._id.c) : "";
if (r._id.s === "planned") add(cat, "plannedMinor", r.sum);
else add(cat, r._id.f ? "fixedMinor" : "variableMinor", r.sum);
}
// Tamamı alış ayına: ilk taksiti bu dönemde olan planların tüm taksitleri gerçekleşmiş sayılır.
if (s.installmentMode === "purchase_month") {
const plans = await TransactionModel.aggregate<{
_id: Types.ObjectId;
first: Date;
items: { categoryId: Types.ObjectId | null; amountMinor: number }[][];
}>([
{
$match: {
...base,
status: { $in: ["posted", "planned"] },
"installment.planId": { $ne: null },
},
},
{
$group: {
_id: "$installment.planId",
first: { $min: "$date" },
items: {
$push: {
$cond: [
{ $gt: [{ $size: { $ifNull: ["$splits", []] } }, 0] },
"$splits",
[{ categoryId: "$categoryId", amountMinor: "$amountMinor" }],
],
},
},
},
},
{ $match: { first: { $gte: start, $lt: end } } },
]);
for (const p of plans)
for (const list of p.items)
for (const it of list)
add(it.categoryId ? String(it.categoryId) : "", "fixedMinor", it.amountMinor);
}
return out;
}
export type BudgetLineView = BudgetLine & { icon: IconKey; color: Swatch; path: string };
export type BudgetView = {
month: string;
currentMonth: string;
period: { from: string; to: string };
elapsedDays: number;
totalDays: number;
lines: BudgetLineView[];
totals: BudgetTotals;
hasBudget: boolean;
/** Limit düzenleyici: etkin gider kategorileri (ağaç sırası) ve mevcut limitler. */
editor: {
categories: { id: string; name: string; depth: number }[];
limits: Record<string, number>;
};
hasPrevious: boolean;
includesHidden: boolean;
canWrite: boolean;
};
function expenseCategories(ctx: Ctx) {
return ctx.flatCategories.filter((c) => c.kind === "expense");
}
export async function getBudget(
userId: string,
householdId: string,
month?: string,
): Promise<BudgetView | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const s = await settingsOf(ctx.m.householdId);
const today = todayIstanbul();
const currentMonth = periodOfDate(today, s.startDay);
const m = month ?? currentMonth;
const period = budgetPeriod(m, s.startDay);
const totalDays = daysBetween(period.from, period.to);
const elapsedDays =
today < period.from ? 0 : today > period.to ? totalDays : daysBetween(period.from, today);
const [doc, prev, amounts] = await Promise.all([
BudgetModel.findOne({ householdId: ctx.m.householdId, month: m }).lean(),
BudgetModel.exists({
householdId: ctx.m.householdId,
month: shiftMonth(m, -1),
"limits.0": { $exists: true },
}),
expenseAmounts(ctx, s, period.from, period.to),
]);
const limits = new Map((doc?.limits ?? []).map((l) => [String(l.categoryId), l.amountMinor]));
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 };
for (const [id, a] of amounts)
if (!known.has(id)) {
orphan.fixedMinor += a.fixedMinor;
orphan.variableMinor += a.variableMinor;
orphan.plannedMinor += a.plannedMinor;
}
const ORPHAN = "__none__";
const inputCats = visibleCats.map((c) => ({
id: c.id,
parentId: c.parentId,
name: c.name,
order: c.order,
}));
if (orphan.fixedMinor + orphan.variableMinor + orphan.plannedMinor > 0) {
inputCats.push({ id: ORPHAN, parentId: null, name: t.uncategorized, order: 1e9 });
amounts.set(ORPHAN, orphan);
}
const { lines, totals } = computeBudget({
categories: inputCats,
limits,
amounts,
elapsedDays,
totalDays,
});
const editorCats = cats.filter((c) => !c.archived);
// Düzenleyici için ağaç sırası ve derinlik (tüm etkin kategoriler, limit 0 ile).
const ordered = computeBudget({
categories: editorCats.map((c) => ({
id: c.id,
parentId: c.parentId,
name: c.name,
order: c.order,
})),
limits: new Map(editorCats.map((c) => [c.id, 0])),
amounts: new Map(),
elapsedDays: 0,
totalDays: 1,
}).lines;
return {
month: m,
currentMonth,
period,
elapsedDays,
totalDays,
lines: lines.map((l) => {
const c = ctx.categoryMap.get(l.categoryId);
return {
...l,
icon: (c?.icon ?? "tag") as IconKey,
color: (c?.color ?? "slate") as Swatch,
path: c?.path ?? l.name,
};
}),
totals,
hasBudget: limits.size > 0,
editor: {
categories: ordered.map((l) => ({ id: l.categoryId, name: l.name, depth: l.depth })),
limits: Object.fromEntries(limits),
},
hasPrevious: Boolean(prev),
includesHidden: s.includePrivate && ctx.hiddenAccountIds.length > 0,
canWrite: hasHouseholdRole(ctx.m.role, "member"),
};
}
async function writeLimits(
ctx: Ctx,
actorId: string,
month: string,
limits: { categoryId: string; amountMinor: number }[],
): Promise<ActionResult> {
const valid = new Set(
expenseCategories(ctx)
.filter((c) => !c.archived)
.map((c) => c.id),
);
const seen = new Set<string>();
for (const l of limits) {
if (!valid.has(l.categoryId) || seen.has(l.categoryId)) return fail(t.errors.category);
seen.add(l.categoryId);
}
const actor = new Types.ObjectId(actorId);
await BudgetModel.updateOne(
{ householdId: ctx.m.householdId, month },
{
$set: {
limits: limits.map((l) => ({
categoryId: new Types.ObjectId(l.categoryId),
amountMinor: l.amountMinor,
})),
updatedBy: actor,
},
$setOnInsert: { createdBy: actor },
},
{ upsert: true },
);
return ok(undefined);
}
export async function saveBudget(
actorId: string,
householdId: string,
input: BudgetLimitsForm,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
return writeLimits(ctx, actorId, input.month, input.limits);
}
/** Önceki ayın limitlerini kopyalar (arşivlenmiş kategoriler atlanır). */
export async function copyPreviousBudget(
actorId: string,
householdId: string,
month: string,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const prev = await BudgetModel.findOne({
householdId: ctx.m.householdId,
month: shiftMonth(month, -1),
}).lean();
if (!prev?.limits.length) return fail(t.errors.noPrevious);
const active = new Set(
expenseCategories(ctx)
.filter((c) => !c.archived)
.map((c) => c.id),
);
return writeLimits(
ctx,
actorId,
month,
prev.limits
.map((l) => ({ categoryId: String(l.categoryId), amountMinor: l.amountMinor }))
.filter((l) => active.has(l.categoryId)),
);
}
/**
* Önceki ayın gerçekleşen giderlerinden limit: ana kategori (kök) bazında toplam, 100 TL'ye
* yukarı yuvarlanır. Kategorisiz gider atlanır.
*/
export async function budgetFromActuals(
actorId: string,
householdId: string,
month: string,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const s = await settingsOf(ctx.m.householdId);
const period = budgetPeriod(shiftMonth(month, -1), s.startDay);
const amounts = await expenseAmounts(ctx, s, period.from, period.to);
const cats = expenseCategories(ctx);
const byId = new Map(cats.map((c) => [c.id, c]));
const rootOf = (id: string) => {
let cur = byId.get(id);
let guard = 0;
while (cur?.parentId && byId.has(cur.parentId) && guard++ < 6) cur = byId.get(cur.parentId);
return cur;
};
const totals = new Map<string, number>();
for (const [id, a] of amounts) {
const root = rootOf(id);
if (!root || root.archived) continue;
totals.set(root.id, (totals.get(root.id) ?? 0) + a.fixedMinor + a.variableMinor);
}
const limits = [...totals]
.filter(([, v]) => v > 0)
.map(([categoryId, v]) => ({ categoryId, amountMinor: roundUpLimit(v) }));
if (!limits.length) return fail(t.errors.noPreviousSpending);
return writeLimits(ctx, actorId, month, limits);
}
/** Dashboard uyarısı: bu ayın %80'i geçilmiş veya aşılmış limitli kategori sayısı. */
export async function countBudgetWarnings(userId: string, householdId: string): Promise<number> {
const b = await getBudget(userId, householdId);
if (!b?.hasBudget) return 0;
return b.lines.filter((l) => l.status === "warning" || l.status === "over").length;
}