feat(budget): gelir planı ve günlük plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,8 @@ 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 { budgetLimitsSchema, budgetMonthSchema, incomeItemsSchema } from "@/shared/schemas/budget";
|
||||
import { saveIncomeItems } from "./plan";
|
||||
import { budgetFromActuals, copyPreviousBudget, saveBudget } from "./service";
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
@@ -42,3 +43,7 @@ export async function copyPreviousBudgetAction(month: unknown) {
|
||||
export async function budgetFromActualsAction(month: unknown) {
|
||||
return run(budgetMonthSchema, month, budgetFromActuals);
|
||||
}
|
||||
|
||||
export async function saveIncomeItemsAction(input: unknown) {
|
||||
return run(incomeItemsSchema, input, saveIncomeItems);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Types } from "mongoose";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||
import { incomeItemsSchema } from "@/shared/schemas/budget";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import { getDailyPlan, getIncomePlan, saveIncomeItems } from "./plan";
|
||||
import { getBudget } from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
const today = todayIstanbul();
|
||||
const month = periodOfDate(today);
|
||||
|
||||
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");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
async function income(
|
||||
date: string,
|
||||
amountMinor: number,
|
||||
status: "posted" | "planned",
|
||||
accountId?: Types.ObjectId,
|
||||
) {
|
||||
const actor = new Types.ObjectId(ayse);
|
||||
const acc =
|
||||
accountId ??
|
||||
(await AccountModel.findOne({ householdId: cakir, name: "Ziraat Maaş" }).lean())!._id;
|
||||
await TransactionModel.create({
|
||||
householdId: new Types.ObjectId(cakir),
|
||||
type: "income",
|
||||
status,
|
||||
date: istanbulDayStart(date)!,
|
||||
accountId: acc,
|
||||
amountMinor,
|
||||
description: "Test gelir",
|
||||
recurringRuleId: status === "planned" ? new Types.ObjectId() : null,
|
||||
occurrenceIndex: status === "planned" ? 0 : null,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
}
|
||||
|
||||
describe("gelir planı", () => {
|
||||
it("gerçekleşen + bekleyen planlı + elle eklenen = beklenen", async () => {
|
||||
const p0 = (await getIncomePlan(ayse, cakir, month))!;
|
||||
const { to } = budgetPeriod(month);
|
||||
await income(to, 3_000_00, "planned");
|
||||
expect(
|
||||
(
|
||||
await saveIncomeItems(
|
||||
ayse,
|
||||
cakir,
|
||||
incomeItemsSchema.parse({ month, items: [{ name: "Prim", amount: "5.000" }] }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
const p = (await getIncomePlan(ayse, cakir, month))!;
|
||||
expect(p.pendingMinor).toBe(p0.pendingMinor + 3_000_00);
|
||||
expect(p.manual).toEqual([expect.objectContaining({ name: "Prim", amountMinor: 5_000_00 })]);
|
||||
expect(p.expectedMinor).toBe(p.actualMinor + p.pendingMinor + 5_000_00);
|
||||
expect(p.entries.some((e) => e.status === "planned" && e.amountMinor === 3_000_00)).toBe(true);
|
||||
// Elle eklenenler bütçe limitlerini bozmaz.
|
||||
expect((await getBudget(ayse, cakir, month))!.editor.limits).toBeTruthy();
|
||||
});
|
||||
|
||||
it("gizli hesaba gelen gelir hane ayarına göre; izleyici yazamaz; başka hane göremez", async () => {
|
||||
const actor = new Types.ObjectId(emre);
|
||||
const secret = await AccountModel.create({
|
||||
householdId: new Types.ObjectId(cakir),
|
||||
name: "Emre Gizli Gelir",
|
||||
type: "bank",
|
||||
openingBalanceMinor: 0,
|
||||
openingDate: istanbulDayStart(shiftMonth(month, -2) + "-01")!,
|
||||
isPrivate: true,
|
||||
ownerUserId: actor,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
const a0 = (await getIncomePlan(ayse, cakir, month))!.actualMinor;
|
||||
await income(today, 1_000_00, "posted", secret._id);
|
||||
expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0 + 1_000_00);
|
||||
await HouseholdModel.updateOne(
|
||||
{ _id: cakir },
|
||||
{ $set: { "settings.includePrivateAccountsInTotals": false } },
|
||||
);
|
||||
expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0);
|
||||
await HouseholdModel.updateOne(
|
||||
{ _id: cakir },
|
||||
{ $set: { "settings.includePrivateAccountsInTotals": true } },
|
||||
);
|
||||
// Sahibi kendi gizli hesabını her zaman görür.
|
||||
expect((await getIncomePlan(emre, cakir, month))!.actualMinor).toBe(a0 + 1_000_00);
|
||||
|
||||
expect(
|
||||
(await saveIncomeItems(zeynep, cakir, incomeItemsSchema.parse({ month, items: [] }))).ok,
|
||||
).toBe(false);
|
||||
expect(await getIncomePlan(fatma, cakir, month)).toBeNull();
|
||||
expect(incomeItemsSchema.safeParse({ month, items: [{ name: "", amount: "1" }] }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
incomeItemsSchema.safeParse({ month, items: [{ name: "X", amount: "0" }] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("günlük plan", () => {
|
||||
it("plan toplamı bütçeye eşit; bugüne kadarki gerçekleşen bütçe motoruyla tutarlı; elde kalacak", async () => {
|
||||
const v = (await getDailyPlan(ayse, cakir))!;
|
||||
const b = (await getBudget(ayse, cakir))!;
|
||||
expect(v.month).toBe(month);
|
||||
expect(v.hasBudget).toBe(true);
|
||||
const last = v.plan.days.at(-1)!;
|
||||
expect(last.cumPlannedMinor).toBe(Math.max(b.totals.limitMinor, v.plan.fixedMinor));
|
||||
expect(v.plan.days).toHaveLength(b.totalDays);
|
||||
expect(v.plan.todayIndex).toBe(b.elapsedDays - 1);
|
||||
// Bugünden sonraki gerçekleşmiş işlem yok: bugüne kadar harcanan = ayın gerçekleşeni.
|
||||
expect(v.plan.actualToDateMinor).toBe(b.totals.actualMinor);
|
||||
expect(v.projectedLeftMinor).toBe(v.income.expectedMinor - b.totals.forecastMinor);
|
||||
expect(v.plannedLeftMinor).toBe(v.income.expectedMinor - b.totals.limitMinor);
|
||||
expect(v.next.month).toBe(shiftMonth(month, 1));
|
||||
});
|
||||
|
||||
it("önceki ay örüntüsü modu: dağılım değişir, toplam korunur; bütçesiz hane", async () => {
|
||||
const even = (await getDailyPlan(ayse, cakir))!;
|
||||
await HouseholdModel.updateOne(
|
||||
{ _id: cakir },
|
||||
{ $set: { "settings.dailyPlanMode": "previous_month_pattern" } },
|
||||
);
|
||||
const pattern = (await getDailyPlan(ayse, cakir))!;
|
||||
await HouseholdModel.updateOne({ _id: cakir }, { $set: { "settings.dailyPlanMode": "even" } });
|
||||
expect(pattern.mode).toBe("previous_month_pattern");
|
||||
expect(pattern.plan.days.at(-1)!.cumPlannedMinor).toBe(even.plan.days.at(-1)!.cumPlannedMinor);
|
||||
expect(pattern.plan.days.map((d) => d.plannedMinor)).not.toEqual(
|
||||
even.plan.days.map((d) => d.plannedMinor),
|
||||
);
|
||||
|
||||
const annemler = ids.household("Annemler");
|
||||
const a = (await getDailyPlan(fatma, annemler))!;
|
||||
expect(a.hasBudget).toBe(false);
|
||||
expect(await getDailyPlan(fatma, cakir)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
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 { TransactionModel } from "@/models/transaction";
|
||||
import { budgetPeriod, daysBetween, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||
import { dailyPlan, patternWeights, type DailyPlan } from "@/shared/daily-plan";
|
||||
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import type { IncomeItemsForm } from "@/shared/schemas/budget";
|
||||
import { getBudget, settingsOf, type Settings } from "./service";
|
||||
|
||||
/**
|
||||
* Gelir planı ve günlük plan (Bölüm 6.9).
|
||||
*
|
||||
* Gelir planı: dönemdeki gerçekleşmiş gelirler + henüz gerçekleşmemiş planlı gelirler (düzenli
|
||||
* işlem tekrarları) + elle eklenen beklenen gelirler. Elle eklenen kalem gelir işlemi olarak
|
||||
* girildiğinde kullanıcı kalemi kaldırır (aksi halde iki kez sayılır).
|
||||
*/
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function baseMatch(ctx: Ctx, s: Settings, type: "income" | "expense", from: string, to: string) {
|
||||
const m: Record<string, unknown> = {
|
||||
householdId: ctx.m.householdId,
|
||||
type,
|
||||
deletedAt: null,
|
||||
status: { $in: ["posted", "planned"] },
|
||||
date: {
|
||||
$gte: istanbulDayStart(from)!,
|
||||
$lt: new Date(istanbulDayStart(to)!.getTime() + DAY_MS),
|
||||
},
|
||||
};
|
||||
if (!s.includePrivate && ctx.hiddenAccountIds.length)
|
||||
m.accountId = { $nin: ctx.hiddenAccountIds };
|
||||
return m;
|
||||
}
|
||||
|
||||
export type IncomeEntry = {
|
||||
id: string;
|
||||
date: string;
|
||||
label: string;
|
||||
amountMinor: number;
|
||||
status: "posted" | "planned";
|
||||
};
|
||||
|
||||
export type IncomePlan = {
|
||||
/** Gerçekleşen gelir. */
|
||||
actualMinor: number;
|
||||
/** Henüz gerçekleşmemiş planlı gelir. */
|
||||
pendingMinor: number;
|
||||
manualMinor: number;
|
||||
/** Beklenen toplam = gerçekleşen + planlı + elle. */
|
||||
expectedMinor: number;
|
||||
/** Planlı/düzenli gelir kalemleri (gerçekleşmiş düzenli gelirler dahil). */
|
||||
entries: IncomeEntry[];
|
||||
manual: { id: string; name: string; amountMinor: number }[];
|
||||
};
|
||||
|
||||
async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise<IncomePlan> {
|
||||
const { from, to } = budgetPeriod(month, s.startDay);
|
||||
const [docs, budget] = await Promise.all([
|
||||
TransactionModel.find(baseMatch(ctx, s, "income", from, to))
|
||||
.select({
|
||||
date: 1,
|
||||
status: 1,
|
||||
amountMinor: 1,
|
||||
description: 1,
|
||||
payeeName: 1,
|
||||
categoryId: 1,
|
||||
recurringRuleId: 1,
|
||||
})
|
||||
.sort({ date: 1 })
|
||||
.lean(),
|
||||
BudgetModel.findOne({ householdId: ctx.m.householdId, month }).lean(),
|
||||
]);
|
||||
let actual = 0;
|
||||
let pending = 0;
|
||||
const entries: IncomeEntry[] = [];
|
||||
for (const d of docs) {
|
||||
if (d.status === "posted") actual += d.amountMinor;
|
||||
else pending += d.amountMinor;
|
||||
if (d.status === "planned" || d.recurringRuleId) {
|
||||
const cat = d.categoryId ? ctx.categoryMap.get(String(d.categoryId)) : undefined;
|
||||
entries.push({
|
||||
id: String(d._id),
|
||||
date: toIstanbulYmd(d.date),
|
||||
label: d.payeeName || d.description || cat?.name || tr.transactions.types.income,
|
||||
amountMinor: d.amountMinor,
|
||||
status: d.status === "posted" ? "posted" : "planned",
|
||||
});
|
||||
}
|
||||
}
|
||||
const manual = (budget?.incomeItems ?? []).map((i) => ({
|
||||
id: String(i._id),
|
||||
name: i.name,
|
||||
amountMinor: i.amountMinor,
|
||||
}));
|
||||
const manualMinor = manual.reduce((a, i) => a + i.amountMinor, 0);
|
||||
return {
|
||||
actualMinor: actual,
|
||||
pendingMinor: pending,
|
||||
manualMinor,
|
||||
expectedMinor: actual + pending + manualMinor,
|
||||
entries,
|
||||
manual,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getIncomePlan(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
month: string,
|
||||
): Promise<IncomePlan | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
return incomePlan(ctx, await settingsOf(ctx.m.householdId), month);
|
||||
}
|
||||
|
||||
export async function saveIncomeItems(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: IncomeItemsForm,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await BudgetModel.updateOne(
|
||||
{ householdId: ctx.m.householdId, month: input.month },
|
||||
{
|
||||
$set: {
|
||||
incomeItems: input.items.map((i) => ({ name: i.name, amountMinor: i.amount })),
|
||||
updatedBy: actor,
|
||||
},
|
||||
$setOnInsert: { createdBy: actor, limits: [] },
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Günlük plan ---
|
||||
|
||||
/** Güne göre gider: sabit (düzenli/taksit; gerçekleşmiş + planlı) ve gerçekleşen (tümü). */
|
||||
async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||
const pipeline: PipelineStage[] = [
|
||||
{ $match: baseMatch(ctx, s, "expense", from, to) },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
d: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } },
|
||||
s: "$status",
|
||||
f: {
|
||||
$or: [
|
||||
{ $ne: [{ $ifNull: ["$recurringRuleId", null] }, null] },
|
||||
{ $ne: [{ $ifNull: ["$installment.planId", null] }, null] },
|
||||
],
|
||||
},
|
||||
},
|
||||
sum: { $sum: "$amountMinor" },
|
||||
},
|
||||
},
|
||||
];
|
||||
const rows = await TransactionModel.aggregate<{
|
||||
_id: { d: string; s: string; f: boolean };
|
||||
sum: number;
|
||||
}>(pipeline);
|
||||
const fixed = new Map<string, number>();
|
||||
const actual = new Map<string, number>();
|
||||
const variable = new Map<string, number>();
|
||||
const add = (m: Map<string, number>, k: string, v: number) => m.set(k, (m.get(k) ?? 0) + v);
|
||||
for (const r of rows) {
|
||||
if (r._id.f) add(fixed, r._id.d, r.sum);
|
||||
if (r._id.s === "posted") {
|
||||
add(actual, r._id.d, r.sum);
|
||||
if (!r._id.f) add(variable, r._id.d, r.sum);
|
||||
}
|
||||
}
|
||||
return { fixed, actual, variable };
|
||||
}
|
||||
|
||||
export type DailyPlanView = {
|
||||
month: string;
|
||||
period: { from: string; to: string };
|
||||
hasBudget: boolean;
|
||||
plan: DailyPlan;
|
||||
mode: "even" | "previous_month_pattern";
|
||||
income: IncomePlan;
|
||||
/** Bütçe ayı sonu tahmini gider (bütçe motorundan). */
|
||||
forecastExpenseMinor: number;
|
||||
/** Bu tempoda ay sonunda elde kalacak: beklenen gelir − tahmini gider. */
|
||||
projectedLeftMinor: number;
|
||||
/** Plana göre elde kalacak: beklenen gelir − bütçe. */
|
||||
plannedLeftMinor: number;
|
||||
next: { month: string; expectedIncomeMinor: number; budgetMinor: number | null };
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
/** Dashboard günlük planı: içinde bulunulan bütçe ayı. */
|
||||
export async function getDailyPlan(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<DailyPlanView | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const s = await settingsOf(ctx.m.householdId);
|
||||
const today = todayIstanbul();
|
||||
const month = periodOfDate(today, s.startDay);
|
||||
const period = budgetPeriod(month, s.startDay);
|
||||
const nextMonth = shiftMonth(month, 1);
|
||||
const mode = s.dailyMode;
|
||||
const [budget, byDay, income, nextIncome, nextBudget] = await Promise.all([
|
||||
getBudget(userId, householdId, month),
|
||||
expensesByDay(ctx, s, period.from, period.to),
|
||||
incomePlan(ctx, s, month),
|
||||
incomePlan(ctx, s, nextMonth),
|
||||
getBudget(userId, householdId, nextMonth),
|
||||
]);
|
||||
if (!budget) return null;
|
||||
|
||||
let weights: number[] | undefined;
|
||||
if (mode === "previous_month_pattern") {
|
||||
const prev = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||
const prevDays = await expensesByDay(ctx, s, prev.from, prev.to);
|
||||
const len = daysBetween(prev.from, prev.to);
|
||||
weights = patternWeights(
|
||||
Array.from({ length: len }, (_, i) => prevDays.variable.get(addDays(prev.from, i)) ?? 0),
|
||||
daysBetween(period.from, period.to),
|
||||
);
|
||||
}
|
||||
const plan = dailyPlan({
|
||||
from: period.from,
|
||||
to: period.to,
|
||||
today,
|
||||
budgetMinor: budget.totals.limitMinor,
|
||||
fixedByDay: byDay.fixed,
|
||||
actualByDay: byDay.actual,
|
||||
weights,
|
||||
});
|
||||
const forecastExpense = budget.totals.forecastMinor;
|
||||
return {
|
||||
month,
|
||||
period,
|
||||
hasBudget: budget.hasBudget,
|
||||
plan,
|
||||
mode,
|
||||
income,
|
||||
forecastExpenseMinor: forecastExpense,
|
||||
projectedLeftMinor: income.expectedMinor - forecastExpense,
|
||||
plannedLeftMinor: income.expectedMinor - budget.totals.limitMinor,
|
||||
next: {
|
||||
month: nextMonth,
|
||||
expectedIncomeMinor: nextIncome.expectedMinor,
|
||||
budgetMinor: nextBudget?.hasBudget ? nextBudget.totals.limitMinor : null,
|
||||
},
|
||||
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user