d77a2efcb8
budgetFor, incomePlan, expensesByDay, incomeByDay ve monthDailyPlan raporlarda ve dashboard grafiğinde birden çok ay için bağlamı yeniden yüklemeden kullanılır. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
9.5 KiB
TypeScript
290 lines
9.5 KiB
TypeScript
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 }[];
|
||
};
|
||
|
||
export 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ü). */
|
||
export 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 };
|
||
}
|
||
|
||
/** Güne göre beklenen gelir (gerçekleşmiş + planlı gelir işlemleri). */
|
||
export async function incomeByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||
const rows = await TransactionModel.aggregate<{ _id: string; sum: number }>([
|
||
{ $match: baseMatch(ctx, s, "income", from, to) },
|
||
{
|
||
$group: {
|
||
_id: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } },
|
||
sum: { $sum: "$amountMinor" },
|
||
},
|
||
},
|
||
]);
|
||
return new Map(rows.map((r) => [r._id, r.sum]));
|
||
}
|
||
|
||
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;
|
||
};
|
||
|
||
/**
|
||
* Bir bütçe ayının günlük planı: sabit giderler kendi günlerine, değişken bütçe hane ayarına göre
|
||
* (eşit ya da önceki ayın örüntüsü) dağıtılır.
|
||
*/
|
||
export async function monthDailyPlan(
|
||
ctx: Ctx,
|
||
s: Settings,
|
||
month: string,
|
||
budgetMinor: number,
|
||
today = todayIstanbul(),
|
||
): Promise<DailyPlan> {
|
||
const period = budgetPeriod(month, s.startDay);
|
||
const byDay = await expensesByDay(ctx, s, period.from, period.to);
|
||
let weights: number[] | undefined;
|
||
if (s.dailyMode === "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),
|
||
);
|
||
}
|
||
return dailyPlan({
|
||
from: period.from,
|
||
to: period.to,
|
||
today,
|
||
budgetMinor,
|
||
fixedByDay: byDay.fixed,
|
||
actualByDay: byDay.actual,
|
||
weights,
|
||
});
|
||
}
|
||
|
||
/** 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, income, nextIncome, nextBudget] = await Promise.all([
|
||
getBudget(userId, householdId, month),
|
||
incomePlan(ctx, s, month),
|
||
incomePlan(ctx, s, nextMonth),
|
||
getBudget(userId, householdId, nextMonth),
|
||
]);
|
||
if (!budget) return null;
|
||
const plan = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
|
||
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"),
|
||
};
|
||
}
|