afc6da1606
- shared/kakeibo: grup devralma, banka maliyeti, kayıt bazında grup, grup özeti (saf, testli) - kategori ve işlem/kalem modellerine kakeiboGroup ve bankCost; form şemaları - migration 016 varsayılanları doldurur; npm run migrate -- --down <ad> ile geri alma - Kakeibo rapor servisi: tutarlar bütçeyle aynı kaynaktan, grup değiştirmeler taşınır Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
354 lines
11 KiB
TypeScript
354 lines
11 KiB
TypeScript
import "server-only";
|
||
import { Types } from "mongoose";
|
||
import { incomePlan } from "@/features/budgets/plan";
|
||
import {
|
||
budgetFor,
|
||
expenseAmounts,
|
||
settingsOf,
|
||
type BudgetLineView,
|
||
type BudgetView,
|
||
type Settings,
|
||
} from "@/features/budgets/service";
|
||
import { loadContext, userNames, type Ctx } from "@/features/transactions/service";
|
||
import { tr } from "@/i18n/tr";
|
||
import { TransactionModel } from "@/models/transaction";
|
||
import type { AccountType } from "@/shared/accounts";
|
||
import type { IconKey, Swatch } from "@/shared/appearance";
|
||
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||
import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from "@/shared/reports";
|
||
|
||
/**
|
||
* Raporlar (Bölüm 6.14). Okuma: viewer. "Ay" bütçe ayıdır (hane ayarı); gider kuralları bütçeyle
|
||
* aynı: gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve başkasının
|
||
* "yalnızca ben" hesabındaki giderler hane ayarına göre (yalnızca tutar). Başkasının gizli
|
||
* hesabı hesap raporunda hiç görünmez.
|
||
*/
|
||
|
||
const DAY_MS = 86_400_000;
|
||
export const ORPHAN = "__none__";
|
||
|
||
export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
|
||
|
||
export async function base(userId: string, householdId: string): Promise<Base | null> {
|
||
const ctx = await loadContext(userId, householdId, "viewer");
|
||
if (!ctx) return null;
|
||
const s = await settingsOf(ctx.m.householdId);
|
||
const today = todayIstanbul();
|
||
return { ctx, s, today, currentMonth: periodOfDate(today, s.startDay) };
|
||
}
|
||
|
||
/** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */
|
||
export async function actualByCategory(b: Base, month: string) {
|
||
const { from, to } = budgetPeriod(month, b.s.startDay);
|
||
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
||
const out = new Map<string, number>();
|
||
for (const [id, a] of amounts) {
|
||
const v = a.fixedMinor + a.variableMinor;
|
||
if (!v) continue;
|
||
const known = id && b.ctx.categoryMap.get(id)?.kind === "expense";
|
||
const key = known ? id : ORPHAN;
|
||
out.set(key, (out.get(key) ?? 0) + v);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export type MonthInfo = {
|
||
month: string;
|
||
from: string;
|
||
to: string;
|
||
current: boolean;
|
||
currentMonth: string;
|
||
};
|
||
|
||
export const monthInfo = (b: Base, month: string): MonthInfo => ({
|
||
month,
|
||
...budgetPeriod(month, b.s.startDay),
|
||
current: month === b.currentMonth,
|
||
currentMonth: b.currentMonth,
|
||
});
|
||
|
||
// --- Kategori dağılımı ---
|
||
|
||
export type CategoryReport = MonthInfo & {
|
||
previous: string;
|
||
breakdown: CategoryBreakdown;
|
||
appearance: Record<string, { icon: IconKey; color: Swatch; path: string }>;
|
||
includesHidden: boolean;
|
||
};
|
||
|
||
export async function getCategoryReport(
|
||
userId: string,
|
||
householdId: string,
|
||
month?: string,
|
||
): Promise<CategoryReport | null> {
|
||
const b = await base(userId, householdId);
|
||
if (!b) return null;
|
||
const m = month ?? b.currentMonth;
|
||
const prev = shiftMonth(m, -1);
|
||
const [current, previous] = await Promise.all([
|
||
actualByCategory(b, m),
|
||
actualByCategory(b, prev),
|
||
]);
|
||
const cats = b.ctx.flatCategories
|
||
.filter((c) => c.kind === "expense")
|
||
.map((c) => ({ id: c.id, parentId: c.parentId, name: c.name, order: c.order }));
|
||
cats.push({ id: ORPHAN, parentId: null, name: tr.reports.uncategorized, order: 1e9 });
|
||
const appearance: CategoryReport["appearance"] = {
|
||
[ORPHAN]: { icon: "tag", color: "slate", path: tr.reports.uncategorized },
|
||
};
|
||
for (const c of b.ctx.categoryMap.values())
|
||
if (c.kind === "expense")
|
||
appearance[String(c.id)] = { icon: c.icon, color: c.color, path: c.path };
|
||
return {
|
||
...monthInfo(b, m),
|
||
previous: prev,
|
||
breakdown: categoryBreakdown(cats, current, previous),
|
||
appearance,
|
||
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||
};
|
||
}
|
||
|
||
// --- Aylık trend ---
|
||
|
||
export type TrendRow = {
|
||
month: string;
|
||
incomeMinor: number;
|
||
expenseMinor: number;
|
||
netMinor: number;
|
||
savingsPct: number | null;
|
||
};
|
||
|
||
export type TrendReport = {
|
||
months: TrendRow[];
|
||
totals: {
|
||
incomeMinor: number;
|
||
expenseMinor: number;
|
||
netMinor: number;
|
||
savingsPct: number | null;
|
||
};
|
||
currentMonth: string;
|
||
};
|
||
|
||
/** Son `count` bütçe ayı (içinde bulunulan dahil): gerçekleşen gelir, gider, net, tasarruf oranı. */
|
||
export async function getTrendReport(
|
||
userId: string,
|
||
householdId: string,
|
||
count = 12,
|
||
): Promise<TrendReport | null> {
|
||
const b = await base(userId, householdId);
|
||
if (!b) return null;
|
||
const months = Array.from({ length: count }, (_, i) => shiftMonth(b.currentMonth, i - count + 1));
|
||
const rows = await Promise.all(
|
||
months.map(async (month) => {
|
||
const [income, expense] = await Promise.all([
|
||
incomePlan(b.ctx, b.s, month),
|
||
actualByCategory(b, month),
|
||
]);
|
||
const incomeMinor = income.actualMinor;
|
||
const expenseMinor = [...expense.values()].reduce((a, v) => a + v, 0);
|
||
return {
|
||
month,
|
||
incomeMinor,
|
||
expenseMinor,
|
||
netMinor: incomeMinor - expenseMinor,
|
||
savingsPct: savingsRate(incomeMinor, expenseMinor),
|
||
};
|
||
}),
|
||
);
|
||
const incomeMinor = rows.reduce((a, r) => a + r.incomeMinor, 0);
|
||
const expenseMinor = rows.reduce((a, r) => a + r.expenseMinor, 0);
|
||
return {
|
||
months: rows,
|
||
totals: {
|
||
incomeMinor,
|
||
expenseMinor,
|
||
netMinor: incomeMinor - expenseMinor,
|
||
savingsPct: savingsRate(incomeMinor, expenseMinor),
|
||
},
|
||
currentMonth: b.currentMonth,
|
||
};
|
||
}
|
||
|
||
// --- Kişi ve hesap bazlı ---
|
||
|
||
export type PersonRow = {
|
||
userId: string;
|
||
name: string;
|
||
expenseMinor: number;
|
||
count: number;
|
||
sharePct: number;
|
||
};
|
||
|
||
export type AccountRow = {
|
||
id: string;
|
||
name: string;
|
||
type: AccountType;
|
||
archived: boolean;
|
||
incomeMinor: number;
|
||
expenseMinor: number;
|
||
transferInMinor: number;
|
||
transferOutMinor: number;
|
||
netMinor: number;
|
||
count: number;
|
||
};
|
||
|
||
export type PeopleAccountsReport = MonthInfo & {
|
||
people: PersonRow[];
|
||
peopleTotalMinor: number;
|
||
accounts: AccountRow[];
|
||
includesHidden: boolean;
|
||
};
|
||
|
||
type TxLean = {
|
||
type: "income" | "expense" | "transfer";
|
||
amountMinor: number;
|
||
accountId: Types.ObjectId | null;
|
||
toAccountId: Types.ObjectId | null;
|
||
createdBy: Types.ObjectId;
|
||
};
|
||
|
||
export async function getPeopleAccountsReport(
|
||
userId: string,
|
||
householdId: string,
|
||
month?: string,
|
||
): Promise<PeopleAccountsReport | null> {
|
||
const b = await base(userId, householdId);
|
||
if (!b) return null;
|
||
const m = month ?? b.currentMonth;
|
||
const info = monthInfo(b, m);
|
||
const docs = await TransactionModel.find({
|
||
householdId: b.ctx.m.householdId,
|
||
deletedAt: null,
|
||
status: "posted",
|
||
date: {
|
||
$gte: istanbulDayStart(info.from)!,
|
||
$lt: new Date(istanbulDayStart(info.to)!.getTime() + DAY_MS),
|
||
},
|
||
})
|
||
.select({ type: 1, amountMinor: 1, accountId: 1, toAccountId: 1, createdBy: 1 })
|
||
.lean<TxLean[]>();
|
||
const hidden = new Set(b.ctx.hiddenAccountIds.map(String));
|
||
|
||
// Kişi: kaydı giren üye; gizli hesap giderleri hane ayarına göre (yalnızca tutar).
|
||
const people = new Map<string, { expense: number; count: number }>();
|
||
for (const d of docs) {
|
||
if (d.type !== "expense") continue;
|
||
if (d.accountId && hidden.has(String(d.accountId)) && !b.s.includePrivate) continue;
|
||
const key = String(d.createdBy);
|
||
const p = people.get(key) ?? { expense: 0, count: 0 };
|
||
p.expense += d.amountMinor;
|
||
p.count++;
|
||
people.set(key, p);
|
||
}
|
||
const names = await userNames([...people.keys()].map((id) => new Types.ObjectId(id)));
|
||
const peopleTotal = [...people.values()].reduce((a, p) => a + p.expense, 0);
|
||
|
||
// Hesap: başkasının gizli hesabı hiç görünmez.
|
||
const acc = new Map<string, Omit<AccountRow, "id" | "name" | "type" | "archived" | "netMinor">>();
|
||
const bump = (
|
||
id: Types.ObjectId | null,
|
||
k: "incomeMinor" | "expenseMinor" | "transferInMinor" | "transferOutMinor",
|
||
v: number,
|
||
) => {
|
||
if (!id || hidden.has(String(id))) return;
|
||
const key = String(id);
|
||
const row = acc.get(key) ?? {
|
||
incomeMinor: 0,
|
||
expenseMinor: 0,
|
||
transferInMinor: 0,
|
||
transferOutMinor: 0,
|
||
count: 0,
|
||
};
|
||
row[k] += v;
|
||
row.count++;
|
||
acc.set(key, row);
|
||
};
|
||
for (const d of docs) {
|
||
if (d.type === "income") bump(d.accountId, "incomeMinor", d.amountMinor);
|
||
else if (d.type === "expense") bump(d.accountId, "expenseMinor", d.amountMinor);
|
||
else {
|
||
bump(d.accountId, "transferOutMinor", d.amountMinor);
|
||
bump(d.toAccountId, "transferInMinor", d.amountMinor);
|
||
}
|
||
}
|
||
const accounts: AccountRow[] = [];
|
||
for (const a of b.ctx.accountMap.values()) {
|
||
if (a.hidden) continue;
|
||
const row = acc.get(String(a.id));
|
||
if (!row && a.archived) continue;
|
||
const r = row ?? {
|
||
incomeMinor: 0,
|
||
expenseMinor: 0,
|
||
transferInMinor: 0,
|
||
transferOutMinor: 0,
|
||
count: 0,
|
||
};
|
||
accounts.push({
|
||
id: String(a.id),
|
||
name: a.name,
|
||
type: a.type,
|
||
archived: a.archived,
|
||
...r,
|
||
netMinor: r.incomeMinor + r.transferInMinor - r.expenseMinor - r.transferOutMinor,
|
||
});
|
||
}
|
||
accounts.sort((x, y) => y.count - x.count || x.name.localeCompare(y.name, "tr"));
|
||
|
||
return {
|
||
...info,
|
||
people: [...people.entries()]
|
||
.map(([id, p]) => ({
|
||
userId: id,
|
||
name: names.get(id) ?? "—",
|
||
expenseMinor: p.expense,
|
||
count: p.count,
|
||
sharePct: sharePct(p.expense, peopleTotal),
|
||
}))
|
||
.sort((x, y) => y.expenseMinor - x.expenseMinor),
|
||
peopleTotalMinor: peopleTotal,
|
||
accounts,
|
||
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||
};
|
||
}
|
||
|
||
// --- Bütçe vs. gerçekleşen ---
|
||
|
||
export type BudgetHistoryRow = {
|
||
month: string;
|
||
limitMinor: number | null;
|
||
actualMinor: number;
|
||
ratioBp: number | null;
|
||
};
|
||
|
||
export type BudgetReport = MonthInfo & {
|
||
budget: BudgetView;
|
||
lines: BudgetLineView[];
|
||
history: BudgetHistoryRow[];
|
||
};
|
||
|
||
/** Seçilen ayın kategori bazında bütçe/gerçekleşen tablosu ve son 6 ayın toplamları. */
|
||
export async function getBudgetReport(
|
||
userId: string,
|
||
householdId: string,
|
||
month?: string,
|
||
): Promise<BudgetReport | null> {
|
||
const b = await base(userId, householdId);
|
||
if (!b) return null;
|
||
const m = month ?? b.currentMonth;
|
||
const months = Array.from({ length: 6 }, (_, i) => shiftMonth(m, i - 5));
|
||
const budgets = await Promise.all(months.map((x) => budgetFor(b.ctx, b.s, x)));
|
||
const budget = budgets.at(-1)!;
|
||
return {
|
||
...monthInfo(b, m),
|
||
budget,
|
||
lines: budget.lines,
|
||
history: budgets.map((x) => ({
|
||
month: x.month,
|
||
limitMinor: x.hasBudget ? x.totals.limitMinor : null,
|
||
actualMinor: x.totals.actualMinor,
|
||
ratioBp: x.hasBudget ? x.totals.ratioBp : null,
|
||
})),
|
||
};
|
||
}
|