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