import { Types } from "mongoose"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createAccount, listAccounts } from "@/features/accounts/service"; import { setCategoryArchived } from "@/features/categories/service"; import { AccountModel } from "@/models/account"; import { CategoryModel } from "@/models/category"; import { TransactionModel } from "@/models/transaction"; import { transactionFilterSchema, type TransactionForm } from "@/shared/schemas/transaction"; import { transactionEffects } from "@/shared/transactions"; import { setupSeededDb, teardownDb } from "@/test/seeded-db"; import { createTransaction, exportTransactions, getTransaction, listTransactions, searchPayees, setTransactionDeleted, updateTransaction, } from "./service"; let ids: Awaited>; let cakir = ""; let annemler = ""; beforeAll(async () => { ids = await setupSeededDb(); cakir = ids.household("Çakır Ailesi"); annemler = ids.household("Annemler"); }); afterAll(teardownDb); const acc = (name: string, householdId = cakir) => AccountModel.findOne({ householdId, name }) .lean() .then((a) => String(a!._id)); const cat = (key: string, householdId = cakir) => CategoryModel.findOne({ householdId, defaultKey: key }) .lean() .then((c) => String(c!._id)); const filters = (x: Record = {}) => transactionFilterSchema.parse(x); function form(over: Partial): TransactionForm { return { type: "expense", amount: 100_00, date: "2026-06-15", accountId: "", toAccountId: null, categoryId: null, splits: [], payee: "", description: "", tags: [], attachmentIds: [], installments: 1, ...over, }; } async function mustCreate(user: string, f: TransactionForm, householdId = cakir) { const r = await createTransaction(ids.user(user), householdId, f); if (!r.ok) throw new Error(`oluşturulamadı: ${r.error} ${JSON.stringify(r.fieldErrors)}`); return r.data.id; } describe("bakiye tutarlılığı", () => { let bank = ""; let card = ""; it("gelir, gider, kart harcaması, karta transfer ve bölünmüş işlem bakiyeye doğru yansır", async () => { const base = { institution: "", mealCardProvider: null, openingDate: "2026-01-01", color: "blue" as const, icon: "landmark" as const, ownerUserId: null, isPrivate: false, overdraftEnabled: false, overdraftLimit: 0, overdraftMonthlyRate: null, overdraftStatementDay: null, cardLimit: 0, cardStatementDay: 1, cardDueKind: "daysAfter" as const, cardDueValue: 10, cardPaymentAccountId: null, cardMinimumRate: null, }; const b = await createAccount(ids.user("ayse"), cakir, { ...base, name: "T Banka", type: "bank", openingBalance: 1_000_00, }); const c = await createAccount(ids.user("ayse"), cakir, { ...base, name: "T Kart", type: "credit_card", openingBalance: 0, }); bank = b.ok ? b.data.id : ""; card = c.ok ? c.data.id : ""; const gida = await cat("market.gida"); const temizlik = await cat("market.temizlik"); await mustCreate( "ayse", form({ type: "income", amount: 500_00, accountId: bank, categoryId: await cat("ek-gelir") }), ); await mustCreate("ayse", form({ amount: 200_00, accountId: bank, categoryId: gida })); await mustCreate("ayse", form({ amount: 300_00, accountId: card, categoryId: gida })); await mustCreate( "ayse", form({ type: "transfer", amount: 300_00, accountId: bank, toAccountId: card }), ); await mustCreate( "ayse", form({ amount: 120_00, accountId: bank, splits: [ { categoryId: gida, amount: 80_00, note: "" }, { categoryId: temizlik, amount: 40_00, note: "Deterjan" }, ], }), ); const balances = (await listAccounts(ids.user("ayse"), cakir))!.accounts; expect(balances.find((a) => a.id === bank)?.balanceMinor).toBe( 1_000_00 + 500_00 - 200_00 - 300_00 - 120_00, ); expect(balances.find((a) => a.id === card)?.balanceMinor).toBe(-300_00 + 300_00); }); it("silme bakiyeden düşer, geri alma geri getirir", async () => { const id = await mustCreate( "emre", form({ amount: 50_00, accountId: bank, categoryId: await cat("market.gida") }), ); const bal = async () => (await listAccounts(ids.user("ayse"), cakir))!.accounts.find((a) => a.id === bank)! .balanceMinor; const before = await bal(); expect((await setTransactionDeleted(ids.user("emre"), cakir, id, true)).ok).toBe(true); expect(await bal()).toBe(before! + 50_00); expect(await getTransaction(ids.user("emre"), cakir, id)).toBeNull(); expect((await setTransactionDeleted(ids.user("emre"), cakir, id, false)).ok).toBe(true); expect(await bal()).toBe(before); }); it("tüm hesaplarda bakiye = açılış + Σ transactionEffects (bağımsız doğrulama)", async () => { const accounts = await AccountModel.find({ householdId: cakir }).lean(); const txs = await TransactionModel.find({ householdId: cakir }).lean(); const expected = new Map(accounts.map((a) => [String(a._id), a.openingBalanceMinor])); for (const tx of txs) { for (const e of transactionEffects({ type: tx.type, status: tx.status, amountMinor: tx.amountMinor, accountId: String(tx.accountId), toAccountId: tx.toAccountId ? String(tx.toAccountId) : null, deleted: Boolean(tx.deletedAt), })) { expected.set(e.accountId, (expected.get(e.accountId) ?? 0) + e.deltaMinor); } } // ayse "yalnızca ben" hesabının sahibi; tüm bakiyeleri görür. for (const a of (await listAccounts(ids.user("ayse"), cakir, { includeArchived: true }))! .accounts) { expect(a.balanceMinor, a.name).toBe(expected.get(a.id)); } }); }); describe("doğrulama ve izolasyon", () => { it("gider için gelir kategorisi, başka hanenin hesabı/kategorisi reddedilir", async () => { const ziraat = await acc("Ziraat Maaş"); const r1 = await createTransaction( ids.user("ayse"), cakir, form({ accountId: ziraat, categoryId: await cat("maas") }), ); expect(r1.ok).toBe(false); const r2 = await createTransaction( ids.user("ayse"), cakir, form({ accountId: await acc("Nakit", annemler), categoryId: await cat("market.gida") }), ); expect(r2.ok).toBe(false); const r3 = await createTransaction( ids.user("ayse"), cakir, form({ accountId: ziraat, categoryId: await cat("market.gida", annemler) }), ); expect(r3.ok).toBe(false); const r4 = await createTransaction( ids.user("ayse"), cakir, form({ accountId: ziraat, splits: [ { categoryId: await cat("market.gida"), amount: 50_00, note: "" }, { categoryId: await cat("market.gida", annemler), amount: 50_00, note: "" }, ], }), ); expect(r4.ok).toBe(false); const r5 = await createTransaction( ids.user("ayse"), cakir, form({ type: "transfer", accountId: ziraat, toAccountId: await acc("Vakıfbank Emekli", annemler), }), ); expect(r5.ok).toBe(false); }); it("izleyici işlem giremez; başka hane üyesi işlemleri göremez", async () => { const r = await createTransaction( ids.user("zeynep"), cakir, form({ accountId: await acc("Cüzdan"), categoryId: await cat("market.gida") }), ); expect(r.ok).toBe(false); expect(await listTransactions(ids.user("fatma"), cakir, filters())).toBeNull(); const cakirTx = await TransactionModel.findOne({ householdId: cakir }).lean(); // fatma kendi hane kimliğiyle Çakır'ın işlemini isterse bulunamaz. expect(await getTransaction(ids.user("fatma"), annemler, String(cakirTx!._id))).toBeNull(); expect( (await setTransactionDeleted(ids.user("fatma"), annemler, String(cakirTx!._id), true)).ok, ).toBe(false); }); }); describe("“yalnızca ben” hesabı işlemleri", () => { it("başkasından tamamen gizlenir; o hesaba yalnızca sahibi işlem girer", async () => { const pluxee = await acc("Ayşe — Pluxee"); const tx = await TransactionModel.findOne({ householdId: cakir, accountId: pluxee }).lean(); const ofEmre = await listTransactions(ids.user("emre"), cakir, filters({ hesap: pluxee })); expect(ofEmre!.total).toBe(0); expect(await getTransaction(ids.user("emre"), cakir, String(tx!._id))).toBeNull(); const ofAyse = await listTransactions(ids.user("ayse"), cakir, filters({ hesap: pluxee })); expect(ofAyse!.total).toBeGreaterThan(0); const exportEmre = await exportTransactions(ids.user("emre"), cakir, filters()); expect(exportEmre!.some((r) => r.account === "Ayşe — Pluxee")).toBe(false); const kafe = await cat("eglence.kafe"); expect( ( await createTransaction( ids.user("emre"), cakir, form({ accountId: pluxee, categoryId: kafe }), ) ).ok, ).toBe(false); expect( ( await updateTransaction( ids.user("emre"), cakir, String(tx!._id), form({ accountId: pluxee, categoryId: kafe }), ) ).ok, ).toBe(false); expect( ( await createTransaction( ids.user("ayse"), cakir, form({ accountId: pluxee, categoryId: kafe }), ) ).ok, ).toBe(true); }); }); describe("arşivli kategori", () => { it("yeni işlemde kullanılamaz, mevcut işlemde korunur", async () => { const giyim = await cat("giyim"); const cuzdan = await acc("Cüzdan"); const id = await mustCreate("ayse", form({ accountId: cuzdan, categoryId: giyim })); expect((await setCategoryArchived(ids.user("ayse"), cakir, giyim, true)).ok).toBe(true); expect( ( await createTransaction( ids.user("ayse"), cakir, form({ accountId: cuzdan, categoryId: giyim }), ) ).ok, ).toBe(false); expect( ( await updateTransaction( ids.user("ayse"), cakir, id, form({ accountId: cuzdan, categoryId: giyim, description: "düzeltme" }), ) ).ok, ).toBe(true); await setCategoryArchived(ids.user("ayse"), cakir, giyim, false); }); }); describe("filtreler", () => { it("üst kategori filtresi alt kategorileri ve bölünmüş kalemleri kapsar", async () => { const market = await cat("market"); const page = await listTransactions(ids.user("ayse"), cakir, filters({ kategori: market })); expect(page!.total).toBe( await TransactionModel.countDocuments({ householdId: cakir, deletedAt: null, effectiveCategoryIds: { $in: await CategoryModel.find({ householdId: cakir, $or: [{ _id: market }, { ancestors: new Types.ObjectId(market) }], }).distinct("_id"), }, }), ); expect(page!.items.some((i) => i.splitCount > 0)).toBe(true); }); it("tarih aralığı İstanbul gününe göre (gün dahil)", async () => { const id = await mustCreate( "ayse", form({ date: "2026-01-15", accountId: await acc("Cüzdan"), categoryId: await cat("market.gida"), payee: "Tarih Testi", }), ); const same = await listTransactions( ids.user("ayse"), cakir, filters({ from: "2026-01-15", to: "2026-01-15", q: "tarih testi" }), ); expect(same!.items.map((i) => i.id)).toEqual([id]); const after = await listTransactions( ids.user("ayse"), cakir, filters({ from: "2026-01-16", q: "tarih testi" }), ); expect(after!.total).toBe(0); }); it("Türkçe arama, tutar aralığı, kişi, etiket ve hesap (transfer hedefi dahil)", async () => { const q = await listTransactions(ids.user("ayse"), cakir, filters({ q: "MİGROS" })); expect(q!.total).toBeGreaterThan(0); expect(q!.items.every((i) => i.payeeName === "Migros")).toBe(true); const range = await listTransactions( ids.user("ayse"), cakir, filters({ min: "1.000", max: "2.000" }), ); expect(range!.items.every((i) => i.amountMinor >= 1_000_00 && i.amountMinor <= 2_000_00)).toBe( true, ); const byEmre = await listTransactions( ids.user("ayse"), cakir, filters({ kisi: ids.user("emre") }), ); expect(byEmre!.items.every((i) => i.createdBy.id === ids.user("emre"))).toBe(true); const tagged = await listTransactions(ids.user("ayse"), cakir, filters({ etiket: "İndirim" })); expect(tagged!.items.every((i) => i.tags.includes("indirim"))).toBe(true); const cuzdan = await acc("Cüzdan"); const byAccount = await listTransactions( ids.user("ayse"), cakir, filters({ hesap: cuzdan, type: "transfer" }), ); expect(byAccount!.total).toBeGreaterThan(0); expect( byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account!.id === cuzdan), ).toBe(true); }); it("geçersiz filtre değerleri yok sayılır (enjeksiyon yok)", async () => { const f = transactionFilterSchema.parse({ hesap: "{$ne:null}", kategori: "x", min: "abc", sayfa: "-3", q: ".*", }); expect(f.hesap).toBeUndefined(); expect(f.kategori).toBeUndefined(); expect(f.min).toBeUndefined(); expect(f.sayfa).toBe(1); // ".*" düz metin olarak aranır (regex kaçışı). expect((await listTransactions(ids.user("ayse"), cakir, f))!.total).toBe(0); }); }); describe("satıcı kuralları", () => { it("son kullanılan kategori ve hesap önerilir", async () => { const cuzdan = await acc("Cüzdan"); const ilac = await cat("saglik.ilac"); await mustCreate("ayse", form({ accountId: cuzdan, categoryId: ilac, payee: "Şifa Eczanesi" })); const [s] = await searchPayees(ids.user("ayse"), cakir, "sifa"); expect(s).toMatchObject({ name: "Şifa Eczanesi", lastAccountId: cuzdan, lastCategoryId: ilac, lastType: "expense", }); // Başka hane satıcıyı görmez. expect(await searchPayees(ids.user("fatma"), annemler, "sifa")).toEqual([]); }); }); describe("CSV", () => { it("bölünmüş işlem kalem başına satır üretir, işaret türe göre", async () => { const rows = (await exportTransactions(ids.user("ayse"), cakir, filters()))!; const split = await TransactionModel.findOne({ householdId: cakir, "splits.1": { $exists: true }, deletedAt: null, }).lean(); const lines = rows.filter((r) => r.no === String(split!._id)); expect(lines).toHaveLength(split!.splits.length); expect(lines.reduce((a, r) => a + r.amountMinor, 0)).toBe(-split!.amountMinor); expect(rows.find((r) => r.type === "income")!.amountMinor).toBeGreaterThan(0); }); });