feat(transactions): ek bağlama, hesap ekstresi ve mutabakat servisi, 004 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 07:55:04 +03:00
parent 2e1f4d7929
commit 2e2e33233f
15 changed files with 1049 additions and 22 deletions
@@ -0,0 +1,242 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { listAccounts } from "@/features/accounts/service";
import { AccountModel } from "@/models/account";
import { AuditLogModel } from "@/models/audit-log";
import { CategoryModel } from "@/models/category";
import { ReconciliationModel } from "@/models/reconciliation";
import { TransactionModel } from "@/models/transaction";
import { todayIstanbul } from "@/shared/dates";
import { reconcileSchema } from "@/shared/schemas/reconciliation";
import type { TransactionForm } from "@/shared/schemas/transaction";
import { shiftMonth } from "@/shared/statement";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
createTransaction,
getTransaction,
setTransactionDeleted,
updateTransaction,
} from "./service";
import {
currentMonth,
getAccountStatement,
previewReconciliation,
reconcileAccount,
} from "./statement";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
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 cat = (key: string) =>
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
.lean()
.then((c) => String(c!._id));
const input = (statementDate: string, statementBalance: string, acceptAdjustment = false) =>
reconcileSchema.parse({ statementDate, statementBalance, acceptAdjustment });
const tl = (minor: number) => (minor / 100).toFixed(2).replace(".", ",");
async function balanceOf(userId: string, accountId: string) {
const list = await listAccounts(userId, cakir);
return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
}
describe("hesap ekstresi", () => {
it("yürüyen bakiye tutarlı; ay sonu = sonraki ay başı; güncel bakiye hesap listesiyle aynı", async () => {
const ziraat = await acc("Ziraat Maaş");
const month = shiftMonth(currentMonth(), -1);
const s = (await getAccountStatement(ayse, cakir, ziraat, month))!;
expect(s.rows.length).toBeGreaterThan(0);
// Yeniden eskiye: ilk satırın bakiyesi ay sonu bakiyesi.
expect(s.rows[0]!.balanceMinor).toBe(s.closingMinor);
expect(s.closingMinor).toBe(s.openingMinor + s.inflowMinor - s.outflowMinor);
const asc = [...s.rows].reverse();
let running = s.openingMinor;
for (const r of asc) {
running += r.deltaMinor;
expect(r.balanceMinor).toBe(running);
}
const next = (await getAccountStatement(ayse, cakir, ziraat, currentMonth()))!;
expect(next.openingMinor).toBe(s.closingMinor);
expect(next.nextMonth).toBeNull();
expect(s.currentBalanceMinor).toBe(await balanceOf(ayse, ziraat));
});
it("gizli hesabın ekstresi yalnızca sahibine; hane dışına kapalı", async () => {
const pluxee = await acc("Ayşe — Pluxee");
expect(await getAccountStatement(ayse, cakir, pluxee, currentMonth())).not.toBeNull();
expect(await getAccountStatement(emre, cakir, pluxee, currentMonth())).toBeNull();
expect(await getAccountStatement(fatma, cakir, await acc("Cüzdan"), currentMonth())).toBeNull();
});
it("gizli hesaba transfer ekstrede görünür ama karşı hesap adı gizlenir", async () => {
const ziraat = await acc("Ziraat Maaş");
const pluxee = await acc("Ayşe — Pluxee");
const r = await createTransaction(ayse, cakir, {
type: "transfer",
amount: 123_45,
date: todayIstanbul(),
accountId: ziraat,
toAccountId: pluxee,
categoryId: null,
splits: [],
payee: "",
description: "",
tags: [],
attachmentIds: [],
} satisfies TransactionForm);
expect(r.ok).toBe(true);
const s = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
const row = s.rows.find((x) => x.item.id === (r.ok ? r.data.id : ""))!;
expect(row.restricted).toBe(true);
expect(row.deltaMinor).toBe(-123_45);
expect(row.item.toAccount!.name).not.toContain("Pluxee");
expect(s.currentBalanceMinor).toBe(await balanceOf(emre, ziraat));
});
});
describe("mutabakat", () => {
it("bakiye tutuyorsa işlemler işaretlenir, düzeltme oluşmaz", async () => {
const cuzdan = await acc("Cüzdan");
const today = todayIstanbul();
const balance = await balanceOf(ayse, cuzdan);
const preview = await previewReconciliation(ayse, cakir, cuzdan, input(today, tl(balance)));
expect(preview).toMatchObject({
ok: true,
data: { differenceMinor: 0, appBalanceMinor: balance },
});
const unreconciled = preview.ok ? preview.data.unreconciledCount : 0;
expect(unreconciled).toBeGreaterThan(0);
const r = await reconcileAccount(ayse, cakir, cuzdan, input(today, tl(balance)));
expect(r).toMatchObject({ ok: true, data: { adjustmentId: null, markedCount: unreconciled } });
const again = await reconcileAccount(ayse, cakir, cuzdan, input(today, tl(balance)));
expect(again).toMatchObject({ ok: true, data: { markedCount: 0 } });
const s = (await getAccountStatement(ayse, cakir, cuzdan, currentMonth()))!;
expect(s.rows.every((row) => row.reconciled)).toBe(true);
expect(s.lastReconciliation).toMatchObject({ date: today, differenceMinor: 0 });
});
it("fark varsa onay ister; onaylanınca düzeltme işlemi oluşur ve bakiye ekstreyle eşitlenir", async () => {
const ziraat = await acc("Ziraat Maaş");
const today = todayIstanbul();
const balance = await balanceOf(ayse, ziraat);
const target = balance - 250_00;
const refused = await reconcileAccount(ayse, cakir, ziraat, input(today, tl(target)));
expect(refused.ok).toBe(false);
expect(await ReconciliationModel.countDocuments({ accountId: ziraat })).toBe(0);
const r = await reconcileAccount(ayse, cakir, ziraat, input(today, tl(target), true));
expect(r.ok).toBe(true);
const adjustmentId = r.ok ? r.data.adjustmentId! : "";
const adj = (await TransactionModel.findById(adjustmentId).lean())!;
expect(adj).toMatchObject({ type: "expense", amountMinor: 250_00, isAdjustment: true });
expect(await balanceOf(ayse, ziraat)).toBe(target);
const audit = await AuditLogModel.findOne({
action: "account.reconcile",
targetId: ziraat,
}).lean();
expect(audit!.diff).toMatchObject({ differenceMinor: -250_00 });
// Düzeltme işlemi düzenlenemez, silinebilir.
const detail = (await getTransaction(ayse, cakir, adjustmentId))!;
expect(detail.isAdjustment).toBe(true);
const edit = await updateTransaction(ayse, cakir, adjustmentId, {
type: "expense",
amount: 1_00,
date: today,
accountId: ziraat,
toAccountId: null,
categoryId: await cat("market"),
splits: [],
payee: "",
description: "",
tags: [],
attachmentIds: [],
});
expect(edit.ok).toBe(false);
expect((await setTransactionDeleted(ayse, cakir, adjustmentId, true)).ok).toBe(true);
});
it("kredi kartında borç pozitif girilir, uygulamada eksi bakiyeyle karşılaştırılır", async () => {
const kart = await acc("Garanti Bonus");
const balance = await balanceOf(ayse, kart);
expect(balance).toBeLessThan(0);
const preview = await previewReconciliation(
ayse,
cakir,
kart,
input(todayIstanbul(), tl(-balance)),
);
expect(preview).toMatchObject({
ok: true,
data: { statementBalanceMinor: balance, differenceMinor: 0 },
});
});
it("işaretli işlemin tutarı değişince işaret kalkar; yalnızca açıklama değişince kalır", async () => {
const cuzdan = await acc("Cüzdan");
const tx = await TransactionModel.findOne({
accountId: cuzdan,
type: "expense",
reconciledAccountIds: cuzdan,
splits: { $size: 0 },
deletedAt: null,
}).lean();
const d = (await getTransaction(ayse, cakir, String(tx!._id)))!;
const base: TransactionForm = {
type: "expense",
amount: d.amountMinor,
date: d.date,
accountId: cuzdan,
toAccountId: null,
categoryId: d.categoryId,
splits: [],
payee: d.payeeName,
description: "yeni açıklama",
tags: d.tags,
attachmentIds: [],
};
expect((await updateTransaction(ayse, cakir, d.id, base)).ok).toBe(true);
expect(
(await TransactionModel.findById(d.id).lean())!.reconciledAccountIds.map(String),
).toEqual([cuzdan]);
expect(
(await updateTransaction(ayse, cakir, d.id, { ...base, amount: d.amountMinor + 1 })).ok,
).toBe(true);
expect((await TransactionModel.findById(d.id).lean())!.reconciledAccountIds).toEqual([]);
});
it("yetki ve doğrulama: izleyici, gizli hesap, gelecek tarih", async () => {
const cuzdan = await acc("Cüzdan");
expect((await reconcileAccount(zeynep, cakir, cuzdan, input(todayIstanbul(), "0"))).ok).toBe(
false,
);
expect(
(await reconcileAccount(emre, cakir, await acc("Ayşe — Pluxee"), input(todayIstanbul(), "0")))
.ok,
).toBe(false);
expect((await previewReconciliation(ayse, cakir, cuzdan, input("2999-01-01", "0"))).ok).toBe(
false,
);
});
});