2e2e33233f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
9.2 KiB
TypeScript
243 lines
9.2 KiB
TypeScript
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,
|
||
);
|
||
});
|
||
});
|