feat(reports): rapor ve dashboard grafiği motorları

- periyot aralıkları (haftalık, aylık, altı aylık, yıllık) ve kümülatif dönüşüm
- kategori payı, alt kategori kırılımı, önceki aya göre değişim, tasarruf oranı
- kategori, trend, kişi/hesap ve bütçe raporu servisleri; gizli hesap kuralları; testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 00:29:25 +03:00
parent d77a2efcb8
commit bc2a8a3bde
9 changed files with 1050 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import "server-only";
import { budgetFor, settingsOf } from "@/features/budgets/service";
import { incomeByDay, incomePlan, monthDailyPlan } from "@/features/budgets/plan";
import { loadContext } from "@/features/transactions/service";
import { budgetPeriod, periodOfDate } from "@/shared/budget";
import { todayIstanbul } from "@/shared/dates";
import {
overviewRange,
type OverviewPeriod,
type OverviewPoint,
type OverviewRange,
} from "@/shared/overview";
/**
* Dashboard ana grafiği (Bölüm 6.13). Bütçe ve gelir planı motorlarıyla aynı kurallar: bütçe ayı,
* taksit modu ve gizli hesap ayarı.
*
* Aylık noktalar: beklenen gelir (gerçekleşen + planlı + elle), bütçe toplamı (bütçe yoksa boş),
* gerçekleşen gider. Günlük noktalar: o günün gelirleri (elle eklenen beklenen gelir dönemin ilk
* gününe), günlük plan payı, o günün gerçekleşen gideri; gelecek günlerde gerçekleşen boş.
*/
export type OverviewData = {
period: OverviewPeriod;
range: OverviewRange;
points: OverviewPoint[];
today: string;
};
export async function getOverview(
userId: string,
householdId: string,
period: OverviewPeriod,
): Promise<OverviewData | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const s = await settingsOf(ctx.m.householdId);
const today = todayIstanbul();
const range = overviewRange(period, today, s.startDay);
if (range.granularity === "month") {
const points = await Promise.all(
range.buckets.map(async (month) => {
const [budget, income] = await Promise.all([
budgetFor(ctx, s, month),
incomePlan(ctx, s, month),
]);
return {
key: month,
plannedIncome: income.expectedMinor,
budget: budget.hasBudget ? budget.totals.limitMinor : null,
actual: budget.totals.actualMinor,
};
}),
);
return { period, range, points, today };
}
// Günlük: aralığın dokunduğu bütçe ayları (hafta iki aya yayılabilir).
const months = [...new Set([range.from, range.to].map((d) => periodOfDate(d, s.startDay)))];
const planned = new Map<string, number | null>();
const actual = new Map<string, number>();
const income = new Map<string, number>();
for (const month of months) {
const [budget, plan] = await Promise.all([budgetFor(ctx, s, month), incomePlan(ctx, s, month)]);
const dp = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today);
for (const d of dp.days) {
planned.set(d.date, budget.hasBudget ? d.plannedMinor : null);
actual.set(d.date, d.actualMinor);
}
const first = budgetPeriod(month, s.startDay).from;
if (plan.manualMinor) income.set(first, (income.get(first) ?? 0) + plan.manualMinor);
}
for (const [d, v] of await incomeByDay(ctx, s, range.from, range.to))
income.set(d, (income.get(d) ?? 0) + v);
const points = range.buckets.map((d) => ({
key: d,
plannedIncome: income.get(d) ?? 0,
budget: planned.get(d) ?? null,
actual: d <= today ? (actual.get(d) ?? 0) : null,
}));
return { period, range, points, today };
}
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import { tr } from "@/i18n/tr";
import { getHouseholdContext } from "@/lib/dal";
import { budgetMonthSchema } from "@/shared/schemas/budget";
/** CSV rotaları için ortak: oturum + aktif hane, ?ay= doğrulaması ve yanıt üretimi. */
export async function csvContext(request: Request) {
const ctx = await getHouseholdContext();
if (!ctx?.household) return null;
const params = new URL(request.url).searchParams;
const ay = budgetMonthSchema.safeParse(params.get("ay"));
return {
userId: ctx.user.id,
householdId: ctx.household.id,
month: ay.success ? ay.data : undefined,
params,
};
}
export const unauthorized = () => new Response(tr.common.unauthorized, { status: 401 });
export const forbidden = () => new Response(tr.common.unauthorized, { status: 403 });
export function csvResponse(csv: string, filename: string) {
return new Response(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
}
/** 14,3 → "14,3" (CSV ondalık virgül). */
export const csvPct = (n: number | null) => (n === null ? "" : String(n).replace(".", ","));
+114
View File
@@ -0,0 +1,114 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getBudget } from "@/features/budgets/service";
import { getOverview } from "@/features/dashboard/service";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
getBudgetReport,
getCategoryReport,
getPeopleAccountsReport,
getTrendReport,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let zeynep = "";
let fatma = "";
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
describe("kategori dağılımı", () => {
it("toplam bütçenin gerçekleşeniyle aynı; oranlar ~%100; başka hane göremez", async () => {
const r = (await getCategoryReport(ayse, cakir))!;
const budget = (await getBudget(ayse, cakir))!;
expect(r.breakdown.totalMinor).toBe(budget.totals.actualMinor);
expect(r.breakdown.totalMinor).toBeGreaterThan(0);
const sum = r.breakdown.rows.reduce((a, x) => a + x.amountMinor, 0);
expect(sum).toBe(r.breakdown.totalMinor);
const pct = r.breakdown.rows.reduce((a, x) => a + x.sharePct, 0);
expect(Math.abs(pct - 100)).toBeLessThan(1);
// Alt kategori kırılımı: çocukların ve doğrudan tutarın toplamı üst satıra eşit.
for (const row of r.breakdown.rows) {
const kids = row.children.reduce((a, c) => a + c.amountMinor, 0);
expect(kids + row.ownMinor).toBe(row.amountMinor);
}
expect(r.appearance[r.breakdown.rows[0]!.id]).toBeDefined();
expect(await getCategoryReport(fatma, cakir)).toBeNull();
});
it("önceki ay seçilebilir", async () => {
const cur = (await getCategoryReport(ayse, cakir))!;
const prev = (await getCategoryReport(ayse, cakir, cur.previous))!;
expect(prev.current).toBe(false);
expect(prev.breakdown.totalMinor).toBe(cur.breakdown.prevTotalMinor);
});
});
describe("aylık trend", () => {
it("12 ay; içinde bulunulan ayın gideri bütçeyle aynı; tasarruf oranı tutarlı", async () => {
const r = (await getTrendReport(ayse, cakir))!;
expect(r.months).toHaveLength(12);
const last = r.months.at(-1)!;
expect(last.month).toBe(r.currentMonth);
expect(last.expenseMinor).toBe((await getBudget(ayse, cakir))!.totals.actualMinor);
for (const m of r.months) {
expect(m.netMinor).toBe(m.incomeMinor - m.expenseMinor);
if (m.incomeMinor === 0) expect(m.savingsPct).toBeNull();
}
expect(r.months.filter((m) => m.incomeMinor > 0).length).toBeGreaterThan(3);
});
});
describe("kişi ve hesap bazlı", () => {
it("kişi toplamları ve oranları; gizli hesap yalnızca sahibine görünür", async () => {
const own = (await getPeopleAccountsReport(ayse, cakir))!;
expect(own.people.reduce((a, p) => a + p.expenseMinor, 0)).toBe(own.peopleTotalMinor);
expect(own.people.every((p) => p.name !== "—")).toBe(true);
expect(own.accounts.map((a) => a.name)).toContain("Ayşe — Pluxee");
for (const a of own.accounts)
expect(a.netMinor).toBe(
a.incomeMinor + a.transferInMinor - a.expenseMinor - a.transferOutMinor,
);
const other = (await getPeopleAccountsReport(zeynep, cakir))!;
expect(other.accounts.map((a) => a.name)).not.toContain("Ayşe — Pluxee");
});
});
describe("bütçe ve gerçekleşen", () => {
it("son 6 ay; son satır seçilen ay", async () => {
const r = (await getBudgetReport(ayse, cakir))!;
expect(r.history).toHaveLength(6);
expect(r.history.at(-1)!.month).toBe(r.month);
expect(r.history.at(-1)!.actualMinor).toBe(r.budget.totals.actualMinor);
});
});
describe("dashboard ana grafik", () => {
it("periyotlar: hafta 7 gün, ay günleri, 6 ve 12 ay; gelecek günlerde gerçekleşen boş", async () => {
const week = (await getOverview(ayse, cakir, "week"))!;
expect(week.points).toHaveLength(7);
expect(week.points.filter((p) => p.key > week.today).every((p) => p.actual === null)).toBe(
true,
);
const month = (await getOverview(ayse, cakir, "month"))!;
const budget = (await getBudget(ayse, cakir))!;
const actual = month.points.reduce((a, p) => a + (p.actual ?? 0), 0);
expect(actual).toBe(budget.totals.actualMinor);
if (budget.hasBudget) {
const planned = month.points.reduce((a, p) => a + (p.budget ?? 0), 0);
expect(planned).toBe(budget.totals.limitMinor);
}
const half = (await getOverview(ayse, cakir, "half"))!;
expect(half.points).toHaveLength(6);
expect(half.points.at(-1)!.actual).toBe(budget.totals.actualMinor);
expect((await getOverview(ayse, cakir, "year"))!.points).toHaveLength(12);
expect(await getOverview(fatma, cakir, "month")).toBeNull();
});
});
+353
View File
@@ -0,0 +1,353 @@
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;
const ORPHAN = "__none__";
type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
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. */
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;
};
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,
})),
};
}