Files
butcem/src/features/budgets/service.int.test.ts
T
2026-11-06 00:58:51 +03:00

244 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});