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