feat(recurring): düzenli işlem kuralları, planlı kayıt üretimi, onay/atla/ertele ve seri düzenleme

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 10:55:05 +03:00
parent eaafe5dd9b
commit 04a17d2775
13 changed files with 1493 additions and 7 deletions
+283
View File
@@ -0,0 +1,283 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { listAccounts } from "@/features/accounts/service";
import {
listTransactions,
updateTransaction,
getTransaction,
} from "@/features/transactions/service";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { RecurringRuleModel } from "@/models/recurring-rule";
import { TransactionModel } from "@/models/transaction";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring";
import { transactionFilterSchema } from "@/shared/schemas/transaction";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
confirmOccurrence,
createRule,
deleteRule,
ensureHorizon,
getPlannedOverview,
postponeOccurrence,
skipOccurrence,
updateRule,
} 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 cat = (key: string) =>
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
.lean()
.then((c) => String(c!._id));
async function form(over: Partial<RecurringRuleFormInput> = {}) {
return recurringRuleFormSchema.parse({
type: "expense",
amount: "1.000,00",
accountId: await acc("Ziraat Maaş"),
categoryId: await cat("faturalar.elektrik"),
payee: "Elektrik",
startDate: addDays(today, -40),
frequency: "monthly",
...over,
});
}
const occurrences = (ruleId: string) =>
TransactionModel.find({ recurringRuleId: ruleId }).sort({ occurrenceIndex: 1 }).lean();
async function balance(accountId: string) {
const list = await listAccounts(ayse, cakir);
return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
}
describe("kural ve planlı kayıtlar", () => {
let ruleId = "";
it("kural 12 ay ileriye planlı kayıt üretir; planlı kayıtlar bakiyeyi etkilemez", async () => {
const ziraat = await acc("Ziraat Maaş");
const before = await balance(ziraat);
const r = await createRule(ayse, cakir, await form({ isEstimate: true }));
expect(r.ok).toBe(true);
ruleId = r.ok ? r.data.id : "";
const occ = await occurrences(ruleId);
// Başlangıç 40 gün önce: 2 geçmiş + 12 ay ileri (13 ya da 14 kayıt, ay uzunluğuna göre).
expect(occ.length).toBeGreaterThanOrEqual(13);
expect(occ.every((o) => o.status === "planned" && o.isEstimate)).toBe(true);
expect(await balance(ziraat)).toBe(before);
expect(await ensureHorizon((await RecurringRuleModel.findById(ruleId))!.householdId)).toBe(0);
});
it("genel bakış: vadesi geçmiş ve yaklaşan; işlem listesi planlıları varsayılan göstermez", async () => {
const o = (await getPlannedOverview(ayse, cakir))!;
const mine = o.overdue.filter((x) => x.recurringRuleId === ruleId);
expect(mine.length).toBeGreaterThanOrEqual(1);
expect(o.upcoming.every((x) => x.date >= today)).toBe(true);
expect(o.rules.find((x) => x.id === ruleId)).toMatchObject({
recurrence: expect.stringContaining("Her ay"),
remaining: null,
});
const list = await listTransactions(ayse, cakir, transactionFilterSchema.parse({}));
expect(list!.items.some((i) => i.status === "planned")).toBe(false);
const planned = await listTransactions(
ayse,
cakir,
transactionFilterSchema.parse({ durum: "planned" }),
);
expect(planned!.total).toBeGreaterThan(0);
});
it("onayla (tutar/tarih düzeltilerek) bakiyeye yansır; atla iptal eder", async () => {
const ziraat = await acc("Ziraat Maaş");
const before = await balance(ziraat);
const [first, second] = await occurrences(ruleId);
const c = await confirmOccurrence(ayse, cakir, String(first!._id), {
amount: 1_234_56,
date: today,
});
expect(c.ok).toBe(true);
const posted = (await TransactionModel.findById(first!._id).lean())!;
expect(posted).toMatchObject({ status: "posted", amountMinor: 1_234_56, isEstimate: false });
expect(await balance(ziraat)).toBe(before - 1_234_56);
// İkinci kez onaylanamaz.
expect(
(await confirmOccurrence(ayse, cakir, String(first!._id), { amount: 1, date: today })).ok,
).toBe(false);
expect((await skipOccurrence(emre, cakir, String(second!._id))).ok).toBe(true);
expect((await TransactionModel.findById(second!._id).lean())!.status).toBe("void");
expect(await balance(ziraat)).toBe(before - 1_234_56);
});
it("ertele: tarih ileri alınır, kayıt seriden ayrılır; geçmişe ertelenemez", async () => {
const occ = await occurrences(ruleId);
const third = occ[2]!;
const current = third.date.toISOString();
expect((await postponeOccurrence(ayse, cakir, String(third._id), "2000-01-01")).ok).toBe(false);
const target = addDays(today, 200);
expect((await postponeOccurrence(ayse, cakir, String(third._id), target)).ok).toBe(true);
const after = (await TransactionModel.findById(third._id).lean())!;
expect(after.detached).toBe(true);
expect(after.date.toISOString()).not.toBe(current);
});
it("tümünü güncelle: planlılar yeni tutarla yeniden üretilir; gerçekleşen, atlanan ve ayrılan korunur", async () => {
const r = await updateRule(ayse, cakir, ruleId, await form({ amount: "1.500,00" }), "all");
expect(r.ok).toBe(true);
const occ = await occurrences(ruleId);
expect(occ[0]).toMatchObject({ status: "posted", amountMinor: 1_234_56 });
expect(occ[1]).toMatchObject({ status: "void" });
expect(occ[2]).toMatchObject({ detached: true, amountMinor: 1_000_00 });
expect(occ.slice(3).every((o) => o.status === "planned" && o.amountMinor === 1_500_00)).toBe(
true,
);
// Sıra numaraları benzersiz.
expect(new Set(occ.map((o) => o.occurrenceIndex)).size).toBe(occ.length);
});
it("bu ve sonrakiler: seri bölünür", async () => {
const occ = await occurrences(ruleId);
const k = 5;
const from = occ.find((o) => o.occurrenceIndex === k)!;
const r = await updateRule(
ayse,
cakir,
ruleId,
await form({ amount: "2.000,00", startDate: addDays(today, 100) }),
"following",
String(from._id),
);
expect(r.ok).toBe(true);
const newId = r.ok ? r.data.id : "";
expect(newId).not.toBe(ruleId);
expect((await RecurringRuleModel.findById(ruleId).lean())!.count).toBe(k);
const old = await occurrences(ruleId);
expect(old.every((o) => o.occurrenceIndex! < k)).toBe(true);
const fresh = await occurrences(newId);
expect(fresh.length).toBeGreaterThan(0);
expect(fresh.every((o) => o.amountMinor === 2_000_00)).toBe(true);
});
it("tek kaydı düzenlemek (yalnızca bu) kaydı seriden ayırır", async () => {
const r = await createRule(
ayse,
cakir,
await form({ startDate: addDays(today, 3), endKind: "count", count: 3 }),
);
const id = r.ok ? r.data.id : "";
const occ = await occurrences(id);
expect(occ).toHaveLength(3);
const d = (await getTransaction(ayse, cakir, String(occ[1]!._id)))!;
expect(d.status).toBe("planned");
const u = await updateTransaction(ayse, cakir, d.id, {
type: "expense",
amount: 777_00,
date: d.date,
accountId: d.account.id,
toAccountId: null,
categoryId: d.categoryId,
splits: [],
payee: d.payeeName,
description: "",
tags: [],
attachmentIds: [],
});
expect(u.ok).toBe(true);
expect(await TransactionModel.findById(d.id).lean()).toMatchObject({
detached: true,
status: "planned",
amountMinor: 777_00,
});
// Silme: bu ve sonrakiler (index 1'den) → yalnızca ilk kalır, kural 1 tekrarla biter.
expect((await deleteRule(ayse, cakir, id, "following", d.id)).ok).toBe(true);
expect(await occurrences(id)).toHaveLength(1);
expect((await deleteRule(ayse, cakir, id, "all")).ok).toBe(true);
expect(await occurrences(id)).toHaveLength(0);
expect((await RecurringRuleModel.findById(id).lean())!.deletedAt).toBeInstanceOf(Date);
});
});
describe("yetki ve izolasyon", () => {
it("izleyici kural oluşturamaz/onaylayamaz; hane dışı göremez", async () => {
expect((await createRule(zeynep, cakir, await form())).ok).toBe(false);
const o = (await getPlannedOverview(zeynep, cakir))!;
const any = o.upcoming[0]!;
expect((await confirmOccurrence(zeynep, cakir, any.id, { amount: 1, date: today })).ok).toBe(
false,
);
expect(await getPlannedOverview(fatma, cakir)).toBeNull();
expect((await skipOccurrence(fatma, cakir, any.id)).ok).toBe(false);
});
it("gizli hesap kuralı ve kayıtları yalnızca sahibine", async () => {
const pluxee = await acc("Ayşe — Pluxee");
expect(
(
await createRule(
emre,
cakir,
await form({ accountId: pluxee, startDate: addDays(today, 1) }),
)
).ok,
).toBe(false);
const r = await createRule(
ayse,
cakir,
await form({ accountId: pluxee, startDate: addDays(today, 1), payee: "Gizli kural" }),
);
const id = r.ok ? r.data.id : "";
const mine = (await getPlannedOverview(ayse, cakir))!;
expect(mine.rules.some((x) => x.id === id)).toBe(true);
const theirs = (await getPlannedOverview(emre, cakir))!;
expect(theirs.rules.some((x) => x.id === id)).toBe(false);
expect(theirs.upcoming.some((x) => x.recurringRuleId === id)).toBe(false);
expect((await deleteRule(emre, cakir, id, "all")).ok).toBe(false);
});
it("form doğrulaması: bitiş tarihi başlangıçtan önce olamaz; transferde hedef hesap", () => {
expect(
recurringRuleFormSchema.safeParse({
type: "expense",
amount: "10",
accountId: "a".repeat(24),
categoryId: "b".repeat(24),
startDate: "2026-05-01",
frequency: "monthly",
endKind: "date",
endDate: "2026-04-01",
}).success,
).toBe(false);
expect(
recurringRuleFormSchema.safeParse({
type: "transfer",
amount: "10",
accountId: "a".repeat(24),
startDate: "2026-05-01",
frequency: "monthly",
}).success,
).toBe(false);
});
});