feat(transactions): işlem servisi, bakiye hesaplama, satıcı kuralları ve izolasyon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 03:23:30 +03:00
parent d18c8a0d76
commit 1208e2d1eb
9 changed files with 1394 additions and 11 deletions
@@ -0,0 +1,434 @@
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<ReturnType<typeof setupSeededDb>>;
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<string, string> = {}) => transactionFilterSchema.parse(x);
function form(over: Partial<TransactionForm>): TransactionForm {
return {
type: "expense",
amount: 100_00,
date: "2026-06-15",
accountId: "",
toAccountId: null,
categoryId: null,
splits: [],
payee: "",
description: "",
tags: [],
...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,
};
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);
});
});