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