3fa1e949e2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
9.4 KiB
TypeScript
244 lines
9.4 KiB
TypeScript
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);
|
||
});
|
||
});
|