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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user