feat(contacts): cari kişiler, hesap ↔ cari transferleri, cari ekstresi ve vade uyarısı, 007 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 11:58:37 +03:00
parent dfb83d6fd4
commit 5d370a062e
18 changed files with 1060 additions and 26 deletions
+272
View File
@@ -0,0 +1,272 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { listAccounts } from "@/features/accounts/service";
import {
getTransaction,
listTransactions,
updateTransaction,
} from "@/features/transactions/service";
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
import { AccountModel } from "@/models/account";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import {
contactEntrySchema,
contactFormSchema,
type ContactEntryInput,
type ContactFormInput,
} from "@/shared/schemas/contact";
import { transactionFilterSchema } from "@/shared/schemas/transaction";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
countDueContacts,
createContact,
createContactEntry,
getContactStatement,
listContacts,
setContactArchived,
updateContact,
updateContactEntry,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
const today = todayIstanbul();
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");
});
afterAll(teardownDb);
const acc = (name: string) =>
AccountModel.findOne({ householdId: cakir, name })
.lean()
.then((a) => String(a!._id));
const contactForm = (over: Partial<ContactFormInput> = {}) =>
contactFormSchema.parse({ name: "Test Kişi", openingDate: today, ...over });
const entry = (over: Partial<ContactEntryInput>) =>
contactEntrySchema.parse({ kind: "lend", amount: "1000", date: today, ...over });
async function balance(userId: string, accountId: string) {
return (await listAccounts(userId, cakir))!.accounts.find((a) => a.id === accountId)!
.balanceMinor!;
}
async function contactBalance(userId: string, id: string) {
return (await listContacts(userId, cakir))!.contacts.find((c) => c.id === id)!.balanceMinor;
}
const sums = async () =>
(await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
describe("cari defteri", () => {
let ahmet = "";
let lendId = "";
it("borç verme ve tahsilat: hesap bakiyeleri değişir, gelir/gider toplamlarına girmez", async () => {
const ziraat = await acc("Ziraat Maaş");
const cuzdan = await acc("Cüzdan");
const [zBefore, cBefore, sBefore] = [
await balance(ayse, ziraat),
await balance(ayse, cuzdan),
await sums(),
];
const c = await createContact(
ayse,
cakir,
contactForm({ name: "Kemal Test", relation: "friend" }),
);
expect(c.ok).toBe(true);
ahmet = c.ok ? c.data.id : "";
const l = await createContactEntry(
ayse,
cakir,
ahmet,
entry({ accountId: ziraat, amount: "5.000" }),
);
expect(l.ok).toBe(true);
lendId = l.ok ? l.data.id : "";
await createContactEntry(
emre,
cakir,
ahmet,
entry({ kind: "collect", accountId: cuzdan, amount: "2.000" }),
);
expect(await balance(ayse, ziraat)).toBe(zBefore - 5_000_00);
expect(await balance(ayse, cuzdan)).toBe(cBefore + 2_000_00);
expect(await contactBalance(ayse, ahmet)).toBe(3_000_00);
expect(await sums()).toEqual(sBefore);
const detail = (await getTransaction(ayse, cakir, lendId))!;
expect(detail).toMatchObject({
type: "transfer",
contact: { kind: "lend", name: "Kemal Test" },
});
expect(detail.toAccount).toBeNull();
});
it("borç alma ve geri ödeme; açılış bakiyesi; ekstre yürüyen bakiye", async () => {
const ziraat = await acc("Ziraat Maaş");
const c = await createContact(
ayse,
cakir,
contactForm({ name: "Hasan Amca", relation: "family", openingBalance: "-1.000" }),
);
const hasan = c.ok ? c.data.id : "";
await createContactEntry(
ayse,
cakir,
hasan,
entry({ kind: "borrow", accountId: ziraat, amount: "10.000" }),
);
await createContactEntry(
ayse,
cakir,
hasan,
entry({ kind: "repay", accountId: ziraat, amount: "4.000" }),
);
expect(await contactBalance(ayse, hasan)).toBe(-7_000_00);
const s = (await getContactStatement(ayse, cakir, hasan))!;
expect(s.rows).toHaveLength(2);
expect(s.rows[0]!.balanceMinor).toBe(-7_000_00);
expect(s.rows[1]!.balanceMinor).toBe(-11_000_00);
expect(s.contact.balanceMinor).toBe(-7_000_00);
const list = (await listContacts(ayse, cakir))!;
expect(list.totals.iOweMinor).toBeGreaterThanOrEqual(7_000_00);
expect(list.totals.owesMeMinor).toBeGreaterThanOrEqual(3_000_00);
});
it("hareket düzenleme: tür değişince yön ve bakiyeler değişir; işlem formuyla düzenlenemez", async () => {
const ziraat = await acc("Ziraat Maaş");
const before = await balance(ayse, ziraat);
const u = await updateContactEntry(
ayse,
cakir,
lendId,
entry({ kind: "borrow", accountId: ziraat, amount: "5.000" }),
);
expect(u.ok).toBe(true);
expect(await balance(ayse, ziraat)).toBe(before + 10_000_00);
expect(await contactBalance(ayse, ahmet)).toBe(-7_000_00);
const d = (await getTransaction(ayse, cakir, lendId))!;
expect(d.account).toBeNull();
expect(d.toAccount?.id).toBe(ziraat);
const viaForm = await updateTransaction(ayse, cakir, lendId, {
type: "transfer",
amount: 1,
date: today,
accountId: ziraat,
toAccountId: await acc("Cüzdan"),
categoryId: null,
splits: [],
payee: "",
description: "",
tags: [],
installments: 1,
attachmentIds: [],
});
expect(viaForm.ok).toBe(false);
});
it("vade uyarısı: yaklaşan, geçmiş, kapalı bakiye", async () => {
const c = await createContact(
ayse,
cakir,
contactForm({
name: "Vade Kişi",
openingBalance: "500",
dueDate: addDays(today, 2),
reminderDays: 3,
}),
);
const id = c.ok ? c.data.id : "";
const before = await countDueContacts(ayse, cakir);
let v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
expect(v.due).toBe("upcoming");
await updateContact(
ayse,
cakir,
id,
contactForm({ name: "Vade Kişi", openingBalance: "500", dueDate: addDays(today, -1) }),
);
v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
expect(v.due).toBe("overdue");
await updateContact(
ayse,
cakir,
id,
contactForm({ name: "Vade Kişi", openingBalance: "0", dueDate: addDays(today, -1) }),
);
v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
expect(v.due).toBe("none");
expect(await countDueContacts(ayse, cakir)).toBe(before - 1);
});
it("aynı ad engellenir; arşivli cariye hareket eklenmez", async () => {
expect((await createContact(ayse, cakir, contactForm({ name: "kemal TEST" }))).ok).toBe(
false,
);
expect((await setContactArchived(ayse, cakir, ahmet, true)).ok).toBe(true);
expect(
(await createContactEntry(ayse, cakir, ahmet, entry({ accountId: await acc("Cüzdan") }))).ok,
).toBe(false);
expect((await setContactArchived(ayse, cakir, ahmet, false)).ok).toBe(true);
});
});
describe("yetki ve gizlilik", () => {
it("izleyici yazamaz; hane dışı göremez; başka hanenin hesabı kullanılamaz", async () => {
expect((await createContact(zeynep, cakir, contactForm({ name: "X" }))).ok).toBe(false);
expect(await listContacts(fatma, cakir)).toBeNull();
const c = await createContact(ayse, cakir, contactForm({ name: "Hesap Testi" }));
const id = c.ok ? c.data.id : "";
const otherHousehold = String(
(await AccountModel.findOne({ name: "Vakıfbank Emekli" }).lean())!._id,
);
expect(
(await createContactEntry(ayse, cakir, id, entry({ accountId: otherHousehold }))).ok,
).toBe(false);
});
it("yalnızca ben cari: başkası listede, ekstrede ve işlemlerde görmez; hesap ekstresinde ad gizlenir", async () => {
const ziraat = await acc("Ziraat Maaş");
const c = await createContact(
ayse,
cakir,
contactForm({ name: "Gizli Kişi", isPrivate: true }),
);
const id = c.ok ? c.data.id : "";
const e = await createContactEntry(
ayse,
cakir,
id,
entry({ accountId: ziraat, amount: "750" }),
);
const txId = e.ok ? e.data.id : "";
expect((await listContacts(emre, cakir))!.contacts.some((x) => x.id === id)).toBe(false);
expect(await getContactStatement(emre, cakir, id)).toBeNull();
expect(await getTransaction(emre, cakir, txId)).toBeNull();
expect((await createContactEntry(emre, cakir, id, entry({ accountId: ziraat }))).ok).toBe(
false,
);
const st = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
const row = st.rows.find((r) => r.item.id === txId)!;
expect(row.restricted).toBe(true);
expect(row.item.contact!.name).not.toBe("Gizli Kişi");
// Ortak hesabın bakiyesi herkes için aynı.
expect(await balance(emre, ziraat)).toBe(await balance(ayse, ziraat));
});
});