From 04a17d27753565853c9f21175e2c3441d067e917 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 10:55:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(recurring):=20d=C3=BCzenli=20i=C5=9Flem=20?= =?UTF-8?q?kurallar=C4=B1,=20planl=C4=B1=20kay=C4=B1t=20=C3=BCretimi,=20on?= =?UTF-8?q?ay/atla/ertele=20ve=20seri=20d=C3=BCzenleme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/005-recurring.ts | 17 + scripts/migrations/index.ts | 3 +- src/features/recurring/actions.ts | 137 +++++ src/features/recurring/service.int.test.ts | 283 ++++++++++ src/features/recurring/service.ts | 607 +++++++++++++++++++++ src/features/transactions/service.ts | 15 +- src/models/audit-log.ts | 1 + src/models/recurring-rule.ts | 52 ++ src/models/transaction.ts | 10 + src/shared/recurrence.test.ts | 95 ++++ src/shared/recurrence.ts | 136 +++++ src/shared/schemas/recurring.test.ts | 50 ++ src/shared/schemas/recurring.ts | 94 ++++ 13 files changed, 1493 insertions(+), 7 deletions(-) create mode 100644 scripts/migrations/005-recurring.ts create mode 100644 src/features/recurring/actions.ts create mode 100644 src/features/recurring/service.int.test.ts create mode 100644 src/features/recurring/service.ts create mode 100644 src/models/recurring-rule.ts create mode 100644 src/shared/recurrence.test.ts create mode 100644 src/shared/recurrence.ts create mode 100644 src/shared/schemas/recurring.test.ts create mode 100644 src/shared/schemas/recurring.ts diff --git a/scripts/migrations/005-recurring.ts b/scripts/migrations/005-recurring.ts new file mode 100644 index 0000000..1102c86 --- /dev/null +++ b/scripts/migrations/005-recurring.ts @@ -0,0 +1,17 @@ +import { RecurringRuleModel } from "@/models/recurring-rule"; +import { TransactionModel } from "@/models/transaction"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "005-recurring", + description: "recurringRules index'leri; işlemlerde tekrar alanları ve benzersiz tekrar index'i", + async up(db) { + const tx = db.collection("transactions"); + await tx.updateMany( + { occurrenceIndex: { $exists: false } }, + { $set: { occurrenceIndex: null, detached: false, isEstimate: false } }, + ); + await RecurringRuleModel.createIndexes(); + await TransactionModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 97c833f..0e2d92b 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -4,10 +4,11 @@ import { migration as m001 } from "./001-initial-indexes"; import { migration as m002 } from "./002-accounts-categories"; import { migration as m003 } from "./003-transactions"; import { migration as m004 } from "./004-attachments-reconciliation"; +import { migration as m005 } from "./005-recurring"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ -export const MIGRATIONS: Migration[] = [m001, m002, m003, m004]; +export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005]; const COLLECTION = "migrations"; diff --git a/src/features/recurring/actions.ts b/src/features/recurring/actions.ts new file mode 100644 index 0000000..3a491ae --- /dev/null +++ b/src/features/recurring/actions.ts @@ -0,0 +1,137 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { confirmOccurrenceSchema, recurringRuleFormSchema } from "@/shared/schemas/recurring"; +import { + confirmOccurrence, + createRule, + deleteRule, + getRuleForm, + postponeOccurrence, + skipOccurrence, + updateRule, + type RuleFormValues, +} from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); +const optionalId = idSchema.optional(); +const scopeSchema = z.enum(["all", "following"]); +const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); + +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +function revalidate() { + revalidatePath("/", "layout"); +} + +export async function createRuleAction(input: unknown): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = recurringRuleFormSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await createRule(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidate(); + return result.ok ? ok({ id: result.data.id }) : result; +} + +export async function updateRuleAction( + ruleId: unknown, + input: unknown, + scope: unknown, + fromTransactionId?: unknown, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(ruleId); + const s = scopeSchema.safeParse(scope); + const from = optionalId.safeParse(fromTransactionId ?? undefined); + if (!id.success || !s.success || !from.success) return fail(tr.common.notFound); + const parsed = recurringRuleFormSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await updateRule( + ctx.user.id, + ctx.household.id, + id.data, + parsed.data, + s.data, + from.data, + ); + if (result.ok) revalidate(); + return result; +} + +export async function deleteRuleAction( + ruleId: unknown, + scope: unknown, + fromTransactionId?: unknown, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(ruleId); + const s = scopeSchema.safeParse(scope); + const from = optionalId.safeParse(fromTransactionId ?? undefined); + if (!id.success || !s.success || !from.success) return fail(tr.common.notFound); + const result = await deleteRule(ctx.user.id, ctx.household.id, id.data, s.data, from.data); + if (result.ok) revalidate(); + return result; +} + +export async function getRuleFormAction(ruleId: unknown): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(ruleId); + if (!id.success) return fail(tr.common.notFound); + const values = await getRuleForm(ctx.user.id, ctx.household.id, id.data); + return values ? ok(values) : fail(tr.common.notFound); +} + +export async function confirmOccurrenceAction( + transactionId: unknown, + input: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(transactionId); + if (!id.success) return fail(tr.common.notFound); + const parsed = confirmOccurrenceSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await confirmOccurrence(ctx.user.id, ctx.household.id, id.data, parsed.data); + if (result.ok) revalidate(); + return result; +} + +export async function skipOccurrenceAction(transactionId: unknown): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(transactionId); + if (!id.success) return fail(tr.common.notFound); + const result = await skipOccurrence(ctx.user.id, ctx.household.id, id.data); + if (result.ok) revalidate(); + return result; +} + +export async function postponeOccurrenceAction( + transactionId: unknown, + date: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(transactionId); + const d = ymd.safeParse(date); + if (!id.success) return fail(tr.common.notFound); + if (!d.success) return fail(tr.validation.date, { date: tr.validation.date }); + const result = await postponeOccurrence(ctx.user.id, ctx.household.id, id.data, d.data); + if (result.ok) revalidate(); + return result; +} diff --git a/src/features/recurring/service.int.test.ts b/src/features/recurring/service.int.test.ts new file mode 100644 index 0000000..fca6de0 --- /dev/null +++ b/src/features/recurring/service.int.test.ts @@ -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>; +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 = {}) { + 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); + }); +}); diff --git a/src/features/recurring/service.ts b/src/features/recurring/service.ts new file mode 100644 index 0000000..7792615 --- /dev/null +++ b/src/features/recurring/service.ts @@ -0,0 +1,607 @@ +import "server-only"; +import mongoose, { Types, type ClientSession } from "mongoose"; +import { + baseFilter, + buildDoc, + loadContext, + loadOwnTransaction, + toItem, + touchPayee, + userNames, + type Ctx, + type LeanTx, + type TransactionListItem, +} from "@/features/transactions/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeAudit } from "@/lib/audit"; +import { toObjectId } from "@/lib/object-id"; +import { RecurringRuleModel } from "@/models/recurring-rule"; +import { TransactionModel } from "@/models/transaction"; +import { + addDays, + describeRecurrence, + horizonDate, + listOccurrences, + type Recurrence, +} from "@/shared/recurrence"; +import type { ConfirmOccurrenceForm, RecurringRuleForm } from "@/shared/schemas/recurring"; +import type { TransactionForm } from "@/shared/schemas/transaction"; +import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates"; +import { foldTr } from "@/shared/search"; +import type { TransactionType } from "@/shared/transactions"; + +/** + * Planlı ve düzenli işlemler (Bölüm 6.7). Okuma: viewer; yazma: member. + * Planlı kayıtlar bakiyeyi etkilemez; "Onayla" ile posted olur. Gizli hesap kuralları ve + * kayıtları işlemlerle aynı kuralla yalnızca hesap sahibine görünür. + */ + +const t = tr.recurring; +/** "Yaklaşan" listesinin kapsadığı gün sayısı. */ +export const UPCOMING_DAYS = 60; + +type RuleDoc = { + _id: Types.ObjectId; + householdId: Types.ObjectId; + type: TransactionType; + amountMinor: number; + isEstimate: boolean; + accountId: Types.ObjectId; + toAccountId?: Types.ObjectId | null; + categoryId?: Types.ObjectId | null; + payeeName: string; + description: string; + tags: string[]; + frequency: Recurrence["frequency"]; + interval: number; + monthlyMode: Recurrence["monthlyMode"]; + startDate: string; + endDate?: string | null; + count?: number | null; + generatedUntil?: string | null; + createdBy: Types.ObjectId; + updatedBy: Types.ObjectId; +}; + +const toRecurrence = (r: RuleDoc): Recurrence => ({ + frequency: r.frequency, + interval: r.interval, + monthlyMode: r.monthlyMode, + startDate: r.startDate, + endDate: r.endDate ?? null, + count: r.count ?? null, +}); + +function asTransactionForm(input: RecurringRuleForm): TransactionForm { + return { + type: input.type, + amount: input.amount, + date: input.startDate, + accountId: input.accountId, + toAccountId: input.toAccountId, + categoryId: input.type === "transfer" ? null : input.categoryId, + splits: [], + payee: input.payee, + description: input.description, + tags: input.tags, + attachmentIds: [], + }; +} + +/** Form → kural alanları (doğrulanmış işlem şablonu + tekrar). */ +function ruleFields(ctx: Ctx, input: RecurringRuleForm, existing?: RuleDoc) { + const built = buildDoc(ctx, asTransactionForm(input), existing); + if (!built.ok) return built; + const d = built.data as { + type: TransactionType; + accountId: Types.ObjectId; + toAccountId: Types.ObjectId | null; + categoryId: Types.ObjectId | null; + amountMinor: number; + payeeName: string; + description: string; + tags: string[]; + }; + return ok({ + type: d.type, + amountMinor: d.amountMinor, + isEstimate: input.isEstimate, + accountId: d.accountId, + toAccountId: d.toAccountId, + categoryId: d.categoryId, + payeeName: d.payeeName, + description: d.description, + tags: d.tags, + frequency: input.frequency, + interval: input.interval, + monthlyMode: input.monthlyMode, + startDate: input.startDate, + endDate: input.endDate, + count: input.count, + }); +} + +/** Kuralın ufka kadar eksik planlı kayıtlarını üretir (mevcut sıra numaraları atlanır). */ +async function generate(rule: RuleDoc, today: string, session?: ClientSession): Promise { + const until = horizonDate(today); + const occurrences = listOccurrences(toRecurrence(rule), until); + const existing = new Set( + ( + await TransactionModel.find( + { recurringRuleId: rule._id }, + { occurrenceIndex: 1 }, + { session }, + ).lean() + ).map((x) => x.occurrenceIndex), + ); + const missing = occurrences.filter((o) => !existing.has(o.index)); + if (missing.length) { + const docs = missing.map((o) => ({ + householdId: rule.householdId, + type: rule.type, + status: "planned", + date: istanbulDayStart(o.date)!, + accountId: rule.accountId, + toAccountId: rule.toAccountId ?? null, + amountMinor: rule.amountMinor, + categoryId: rule.categoryId ?? null, + splits: [], + effectiveCategoryIds: rule.categoryId ? [rule.categoryId] : [], + payeeName: rule.payeeName, + description: rule.description, + searchText: foldTr(`${rule.payeeName} ${rule.description}`), + tags: rule.tags, + isEstimate: rule.isEstimate, + recurringRuleId: rule._id, + occurrenceIndex: o.index, + createdBy: rule.createdBy, + updatedBy: rule.updatedBy, + })); + try { + await TransactionModel.insertMany(docs, { session, ordered: false }); + } catch (error) { + // Eşzamanlı üretim: benzersiz index kopyayı reddeder (transaction dışında). + if (session || (error as { code?: number }).code !== 11000) throw error; + } + } + await RecurringRuleModel.updateOne( + { _id: rule._id }, + { $set: { generatedUntil: until } }, + { session }, + ); + return missing.length; +} + +/** Hanedeki kuralların ufkunu ilerletir (okuma sırasında; zamanlanmış görev gerekmez). */ +export async function ensureHorizon(householdId: Types.ObjectId, today = todayIstanbul()) { + const until = horizonDate(today); + const rules = await RecurringRuleModel.find({ + householdId, + deletedAt: null, + $or: [{ generatedUntil: null }, { generatedUntil: { $lt: until } }], + }).lean(); + let created = 0; + for (const rule of rules) created += await generate(rule, today); + return created; +} + +function ruleVisible(ctx: Ctx, rule: Pick) { + return [rule.accountId, rule.toAccountId] + .filter(Boolean) + .every((id) => !ctx.accountMap.get(String(id))?.hidden); +} + +async function loadRule(ctx: Ctx, ruleId: string, session?: ClientSession) { + const id = toObjectId(ruleId); + if (!id) return null; + const rule = await RecurringRuleModel.findOne( + { _id: id, householdId: ctx.m.householdId, deletedAt: null }, + null, + { session }, + ).lean(); + return rule && ruleVisible(ctx, rule) ? rule : null; +} + +/** "Bu ve sonrakiler" için başlangıç tekrarı (kurala ait olmalı). */ +async function occurrenceIndexOf(rule: RuleDoc, transactionId: string | undefined) { + const id = transactionId ? toObjectId(transactionId) : null; + if (!id) return null; + const tx = await TransactionModel.findOne( + { _id: id, recurringRuleId: rule._id, householdId: rule.householdId }, + { occurrenceIndex: 1 }, + ).lean(); + return tx?.occurrenceIndex ?? null; +} + +// --- Kural yazma --- + +export async function createRule( + actorId: string, + householdId: string, + input: RecurringRuleForm, +): Promise> { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const fields = ruleFields(ctx, input); + if (!fields.ok) return fields; + const actor = new Types.ObjectId(actorId); + let out = { id: "", generated: 0 }; + await mongoose.connection.transaction(async (session) => { + const [rule] = await RecurringRuleModel.create( + [{ ...fields.data, householdId: ctx.m.householdId, createdBy: actor, updatedBy: actor }], + { session }, + ); + const generated = await generate(rule!.toObject() as RuleDoc, todayIstanbul(), session); + out = { id: String(rule!._id), generated }; + }); + return ok(out); +} + +/** + * Seri düzenleme. "all": kural güncellenir, seriden ayrılmamış planlı kayıtlar yeniden üretilir + * (gerçekleşmiş/atlanmış kayıtlar korunur). "following": seri bu tekrardan bölünür — eski kural + * önceki tekrarda biter, yeni kural bu tekrardan itibaren yeni değerlerle başlar. + */ +export async function updateRule( + actorId: string, + householdId: string, + ruleId: string, + input: RecurringRuleForm, + scope: "all" | "following", + fromTransactionId?: string, +): Promise> { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const rule = await loadRule(ctx, ruleId); + if (!rule) return fail(tr.common.notFound); + const fields = ruleFields(ctx, input, rule); + if (!fields.ok) return fields; + const actor = new Types.ObjectId(actorId); + const from = scope === "following" ? await occurrenceIndexOf(rule, fromTransactionId) : 0; + if (from === null) return fail(tr.common.notFound); + const today = todayIstanbul(); + let id = String(rule._id); + + await mongoose.connection.transaction(async (session) => { + if (from === 0) { + await RecurringRuleModel.updateOne( + { _id: rule._id }, + { $set: { ...fields.data, generatedUntil: null, updatedBy: actor } }, + { session }, + ); + await TransactionModel.deleteMany( + { + recurringRuleId: rule._id, + status: "planned", + ...(scope === "all" ? { detached: false } : {}), + }, + { session }, + ); + const fresh = await RecurringRuleModel.findById(rule._id, null, { session }).lean(); + await generate(fresh!, today, session); + return; + } + // Böl: eski kural `from` tekrarından önce biter. + await RecurringRuleModel.updateOne( + { _id: rule._id }, + { $set: { count: from, updatedBy: actor } }, + { session }, + ); + await TransactionModel.deleteMany( + { recurringRuleId: rule._id, status: "planned", occurrenceIndex: { $gte: from } }, + { session }, + ); + const [next] = await RecurringRuleModel.create( + [{ ...fields.data, householdId: ctx.m.householdId, createdBy: actor, updatedBy: actor }], + { session }, + ); + id = String(next!._id); + await generate(next!.toObject() as RuleDoc, today, session); + }); + return ok({ id }); +} + +export async function deleteRule( + actorId: string, + householdId: string, + ruleId: string, + scope: "all" | "following", + fromTransactionId?: string, +): Promise> { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const rule = await loadRule(ctx, ruleId); + if (!rule) return fail(tr.common.notFound); + const from = scope === "following" ? await occurrenceIndexOf(rule, fromTransactionId) : 0; + if (from === null) return fail(tr.common.notFound); + const actor = new Types.ObjectId(actorId); + let removed = 0; + await mongoose.connection.transaction(async (session) => { + const res = await TransactionModel.deleteMany( + { recurringRuleId: rule._id, status: "planned", occurrenceIndex: { $gte: from } }, + { session }, + ); + removed = res.deletedCount; + await RecurringRuleModel.updateOne( + { _id: rule._id }, + from === 0 + ? { $set: { deletedAt: new Date(), updatedBy: actor } } + : { $set: { count: from, updatedBy: actor } }, + { session }, + ); + await writeAudit( + { + action: "recurring.delete", + actorId: actor, + householdId: ctx.m.householdId, + targetType: "recurringRule", + targetId: String(rule._id), + diff: { scope, fromIndex: from, removedPlanned: removed }, + }, + session, + ); + }); + return ok({ removed }); +} + +// --- Planlı kayıt işlemleri --- + +async function loadPlanned(ctx: Ctx, transactionId: string) { + const tx = await loadOwnTransaction(ctx, transactionId); + return tx && tx.status === "planned" ? tx : null; +} + +/** Onayla: planlı → gerçekleşti; tutar ve tarih düzeltilebilir. */ +export async function confirmOccurrence( + actorId: string, + householdId: string, + transactionId: string, + input: ConfirmOccurrenceForm, +): Promise { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const tx = await loadPlanned(ctx, transactionId); + if (!tx) return fail(tr.common.notFound); + const date = istanbulDayStart(input.date); + if (!date) return fail(tr.validation.date, { date: tr.validation.date }); + if (tx.splits?.length && input.amount !== tx.amountMinor) return fail(t.errors.splitAmount); + // Arşivlenmiş/kapalı hesap veya kategoriye bağlı kayıt onaylanamaz. + for (const id of [tx.accountId, tx.toAccountId].filter(Boolean)) { + const a = ctx.accountMap.get(String(id)); + if (!a || a.archived) return fail(tr.transactions.validation.accountInvalid); + } + const payeeId = await touchPayee( + ctx.m.householdId, + tx.payeeName ?? "", + tx.type, + tx.accountId, + tx.categoryId ?? null, + ); + await TransactionModel.updateOne( + { _id: tx._id, householdId: ctx.m.householdId, status: "planned" }, + { + $set: { + status: "posted", + amountMinor: input.amount, + date, + isEstimate: false, + payeeId, + updatedBy: new Types.ObjectId(actorId), + }, + }, + ); + return ok(undefined); +} + +/** Atla: bu tekrar gerçekleşmeyecek (iptal); seri devam eder. */ +export async function skipOccurrence( + actorId: string, + householdId: string, + transactionId: string, +): Promise { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const tx = await loadPlanned(ctx, transactionId); + if (!tx) return fail(tr.common.notFound); + await TransactionModel.updateOne( + { _id: tx._id, householdId: ctx.m.householdId, status: "planned" }, + { $set: { status: "void", updatedBy: new Types.ObjectId(actorId) } }, + ); + return ok(undefined); +} + +/** Ertele: tarihi ileri al (seri tarihleri değişmez; kayıt seriden ayrılır). */ +export async function postponeOccurrence( + actorId: string, + householdId: string, + transactionId: string, + ymd: string, +): Promise { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const tx = await loadPlanned(ctx, transactionId); + if (!tx) return fail(tr.common.notFound); + const date = istanbulDayStart(ymd); + if (!date || ymd <= toIstanbulYmd(tx.date)) + return fail(t.errors.postponeDate, { date: t.errors.postponeDate }); + await TransactionModel.updateOne( + { _id: tx._id, householdId: ctx.m.householdId, status: "planned" }, + { $set: { date, detached: true, updatedBy: new Types.ObjectId(actorId) } }, + ); + return ok(undefined); +} + +// --- Okuma --- + +export type PlannedItem = TransactionListItem & { + recurringRuleId: string | null; + isEstimate: boolean; + /** "3/12" (tekrar sayılı seride). */ + seriesPosition: string | null; +}; + +export type RuleView = { + id: string; + type: TransactionType; + title: string; + amountMinor: number; + isEstimate: boolean; + recurrence: string; + account: string; + toAccount: string | null; + category: string | null; + nextDate: string | null; + /** Kalan planlı kayıt (tekrar sayılı seride kalan tekrar). */ + remaining: number | null; + startDate: string; + endDate: string | null; + count: number | null; +}; + +export type PlannedOverview = { + overdue: PlannedItem[]; + upcoming: PlannedItem[]; + rules: RuleView[]; +}; + +async function plannedItems(ctx: Ctx, filter: Record, limit = 500) { + const rows = (await TransactionModel.find({ ...baseFilter(ctx), status: "planned", ...filter }) + .sort({ date: 1, createdAt: 1 }) + .limit(limit) + .lean()) as unknown as LeanTx[]; + const ruleIds = [ + ...new Set( + rows + .map((r) => r.recurringRuleId) + .filter(Boolean) + .map(String), + ), + ]; + const rules = await RecurringRuleModel.find({ _id: { $in: ruleIds } }, { count: 1 }).lean(); + const counts = new Map(rules.map((r) => [String(r._id), r.count ?? null])); + const names = await userNames(rows.map((r) => r.createdBy as Types.ObjectId)); + return rows.map((r): PlannedItem => { + const ruleId = r.recurringRuleId ? String(r.recurringRuleId) : null; + const count = ruleId ? counts.get(ruleId) : null; + return { + ...toItem(ctx, r, names), + recurringRuleId: ruleId, + isEstimate: Boolean(r.isEstimate), + seriesPosition: + count && r.occurrenceIndex !== null && r.occurrenceIndex !== undefined + ? `${r.occurrenceIndex + 1}/${count}` + : null, + }; + }); +} + +export async function getPlannedOverview( + userId: string, + householdId: string, +): Promise { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return null; + await ensureHorizon(ctx.m.householdId); + const today = todayIstanbul(); + const todayStart = istanbulDayStart(today)!; + const end = istanbulDayStart(addDays(today, UPCOMING_DAYS + 1))!; + const [overdue, upcoming, rules] = await Promise.all([ + plannedItems(ctx, { date: { $lt: todayStart } }), + plannedItems(ctx, { date: { $gte: todayStart, $lt: end } }), + listRules(ctx), + ]); + return { overdue, upcoming, rules }; +} + +async function listRules(ctx: Ctx): Promise { + const rules = ( + await RecurringRuleModel.find({ householdId: ctx.m.householdId, deletedAt: null }) + .sort({ createdAt: 1 }) + .lean() + ).filter((r) => ruleVisible(ctx, r)); + const next = await TransactionModel.aggregate<{ + _id: Types.ObjectId; + date: Date | null; + planned: number; + done: number; + }>([ + { $match: { recurringRuleId: { $in: rules.map((r) => r._id) }, deletedAt: null } }, + { + $group: { + _id: "$recurringRuleId", + date: { $min: { $cond: [{ $eq: ["$status", "planned"] }, "$date", null] } }, + planned: { $sum: { $cond: [{ $eq: ["$status", "planned"] }, 1, 0] } }, + done: { $sum: { $cond: [{ $eq: ["$status", "planned"] }, 0, 1] } }, + }, + }, + ]); + const nextMap = new Map(next.map((x) => [String(x._id), x])); + const name = (id?: Types.ObjectId | null) => + id ? (ctx.accountMap.get(String(id))?.name ?? "—") : null; + return rules + .map((r): RuleView => { + const n = nextMap.get(String(r._id)); + const category = r.categoryId ? ctx.categoryMap.get(String(r.categoryId)) : undefined; + return { + id: String(r._id), + type: r.type, + title: r.payeeName || r.description || category?.name || tr.transactions.types[r.type], + amountMinor: r.amountMinor, + isEstimate: r.isEstimate, + recurrence: describeRecurrence(r), + account: name(r.accountId)!, + toAccount: name(r.toAccountId), + category: category?.path ?? null, + nextDate: n?.date ? toIstanbulYmd(n.date) : null, + // Tekrar sayılı seride: toplam − gerçekleşen/atlanan; bitiş tarihlide: kalan planlı kayıt. + remaining: r.count + ? Math.max(0, r.count - (n?.done ?? 0)) + : r.endDate + ? (n?.planned ?? 0) + : null, + startDate: r.startDate, + endDate: r.endDate ?? null, + count: r.count ?? null, + }; + }) + .filter((r) => r.nextDate !== null || r.remaining === null); +} + +/** Düzenleme formu için kural değerleri. */ +export async function getRuleForm(userId: string, householdId: string, ruleId: string) { + const ctx = await loadContext(userId, householdId, "member"); + if (!ctx) return null; + const r = await loadRule(ctx, ruleId); + if (!r) return null; + return { + id: String(r._id), + type: r.type, + amountMinor: r.amountMinor, + isEstimate: r.isEstimate, + accountId: String(r.accountId), + toAccountId: r.toAccountId ? String(r.toAccountId) : null, + categoryId: r.categoryId ? String(r.categoryId) : null, + payee: r.payeeName, + description: r.description, + tags: r.tags, + startDate: r.startDate, + frequency: r.frequency, + interval: r.interval, + monthlyMode: r.monthlyMode, + endDate: r.endDate ?? null, + count: r.count ?? null, + }; +} +export type RuleFormValues = NonNullable>>; + +/** Dashboard uyarısı: vadesi geçmiş onaylanmamış planlı kayıt sayısı. */ +export async function countOverdue(userId: string, householdId: string): Promise { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return 0; + return TransactionModel.countDocuments({ + ...baseFilter(ctx), + status: "planned", + date: { $lt: istanbulDayStart(todayIstanbul())! }, + }); +} diff --git a/src/features/transactions/service.ts b/src/features/transactions/service.ts index 1860941..8f9a4fb 100644 --- a/src/features/transactions/service.ts +++ b/src/features/transactions/service.ts @@ -130,7 +130,7 @@ export async function loadContext( export type Ctx = NonNullable>>; /** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */ -function baseFilter(ctx: Ctx): Record { +export function baseFilter(ctx: Ctx): Record { const f: Record = { householdId: ctx.m.householdId, deletedAt: null }; if (ctx.hiddenAccountIds.length) { f.accountId = { $nin: ctx.hiddenAccountIds }; @@ -206,7 +206,7 @@ type Existing = { splits?: { categoryId: Types.ObjectId }[]; }; -function buildDoc( +export function buildDoc( ctx: Ctx, input: TransactionForm, existing?: Existing, @@ -280,7 +280,7 @@ function buildDoc( } /** Satıcı kuralını günceller (son kategori ve hesap) ve kimliğini döner. */ -async function touchPayee( +export async function touchPayee( householdId: Types.ObjectId, name: string, type: TransactionType, @@ -384,7 +384,7 @@ async function attach( ); } -async function loadOwnTransaction( +export async function loadOwnTransaction( ctx: Ctx, transactionId: string, { includeDeleted = false } = {}, @@ -441,6 +441,8 @@ export async function updateTransaction( payeeId, updatedBy: new Types.ObjectId(actorId), ...(balanceChanged ? { reconciledAccountIds: [] } : {}), + // Serinin planlı tekrarı tek başına düzenlendi ("yalnızca bu"). + ...(current.recurringRuleId && current.status === "planned" ? { detached: true } : {}), }, }, { session }, @@ -582,7 +584,8 @@ function filterQuery(ctx: Ctx, f: TransactionFilters): Record { }; } if (f.type) q.type = f.type; - if (f.durum) q.status = f.durum; + // Planlı kayıtlar (12 ay ileriye kadar) yalnızca açıkça istenince listelenir (/planli sayfası). + q.status = f.durum ?? { $ne: "planned" }; if (f.hesap) { const id = toObjectId(f.hesap); // Gizli hesap filtresi sonuç vermez (baseFilter zaten dışlar). @@ -630,7 +633,7 @@ export async function listTransactions( { $match: { ...query, - status: query.status ?? "posted", + status: filters.durum ?? "posted", type: query.type ?? { $in: ["income", "expense"] }, }, }, diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index ef87e05..d333a01 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -27,6 +27,7 @@ export const AUDIT_ACTIONS = [ "category.move", "transaction.delete", "transaction.restore", + "recurring.delete", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/recurring-rule.ts b/src/models/recurring-rule.ts new file mode 100644 index 0000000..d8247c9 --- /dev/null +++ b/src/models/recurring-rule.ts @@ -0,0 +1,52 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { FREQUENCIES, MONTHLY_MODES } from "@/shared/recurrence"; +import { TRANSACTION_TYPES } from "@/shared/transactions"; + +/** + * Düzenli işlem kuralı (Bölüm 6.7). Her tekrar için `planned` durumunda bir işlem üretilir + * (`transactions.recurringRuleId` + `occurrenceIndex`, benzersiz). Bitiş tarihsiz kurallarda + * ufuk 12 ay; okuma sırasında ilerletilir. + */ +const recurringRuleSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + // İşlem şablonu (bölme yok). + type: { type: String, enum: TRANSACTION_TYPES, required: true }, + amountMinor: { type: Number, required: true, min: 1 }, + /** Tutar tahmini mi (ör. fatura)? Onaylarken düzeltilir. */ + isEstimate: { type: Boolean, default: false }, + accountId: { type: Schema.Types.ObjectId, required: true }, + toAccountId: { type: Schema.Types.ObjectId, default: null }, + categoryId: { type: Schema.Types.ObjectId, default: null }, + payeeName: { type: String, default: "", maxlength: 80 }, + description: { type: String, default: "", maxlength: 500 }, + tags: { type: [String], default: [] }, + // Tekrar. + frequency: { type: String, enum: FREQUENCIES, required: true }, + interval: { type: Number, default: 1, min: 1, max: 12 }, + monthlyMode: { type: String, enum: MONTHLY_MODES, default: "day" }, + /** "YYYY-MM-DD" (İstanbul günü). */ + startDate: { type: String, required: true }, + endDate: { type: String, default: null }, + count: { type: Number, default: null, min: 1 }, + /** Üretilen son ufuk ("YYYY-MM-DD"); null → hiç üretilmedi. */ + generatedUntil: { type: String, default: null }, + deletedAt: { type: Date, default: null }, + seedRef: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "recurringRules" }, +); + +recurringRuleSchema.index({ householdId: 1, deletedAt: 1, generatedUntil: 1 }); +recurringRuleSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type RecurringRule = InferSchemaType; + +export const RecurringRuleModel: Model = + (models.RecurringRule as Model | undefined) ?? + model("RecurringRule", recurringRuleSchema); diff --git a/src/models/transaction.ts b/src/models/transaction.ts index b66e115..e9bd92d 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -39,6 +39,12 @@ const transactionSchema = new Schema( attachmentCount: { type: Number, default: 0 }, // İleri fazlar: planlı işlem kuralı (7), taksit (7), kart dönemi (7), cari (8). recurringRuleId: { type: Schema.Types.ObjectId, default: null }, + /** Serideki sıra (0'dan); kuralla birlikte benzersiz (tekrar üretimi idempotent). */ + occurrenceIndex: { type: Number, default: null }, + /** Tek başına düzenlendi/ertelendi: "tümü" güncellemesinde korunur. */ + detached: { type: Boolean, default: false }, + /** Planlı tutar tahmini (ör. fatura); onaylarken kesinleşir. */ + isEstimate: { type: Boolean, default: false }, installment: { type: new Schema( { planId: Schema.Types.ObjectId, index: Number, count: Number }, @@ -70,6 +76,10 @@ transactionSchema.index({ householdId: 1, accountId: 1, date: -1 }); transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 }); transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 }); transactionSchema.index({ householdId: 1, tags: 1 }); +transactionSchema.index( + { recurringRuleId: 1, occurrenceIndex: 1 }, + { unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } }, +); transactionSchema.index( { householdId: 1, seedRef: 1 }, { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, diff --git a/src/shared/recurrence.test.ts b/src/shared/recurrence.test.ts new file mode 100644 index 0000000..89a8b57 --- /dev/null +++ b/src/shared/recurrence.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + addDays, + addMonths, + describeRecurrence, + isFinished, + listOccurrences, + occurrenceDate, + type Recurrence, +} from "./recurrence"; + +const rule = (over: Partial): Recurrence => ({ + frequency: "monthly", + interval: 1, + monthlyMode: "day", + startDate: "2026-01-31", + endDate: null, + count: null, + ...over, +}); + +describe("tarih aritmetiği", () => { + it("gün ve ay ekleme, ay sonu taşması", () => { + expect(addDays("2026-12-30", 3)).toBe("2027-01-02"); + expect(addDays("2028-03-01", -1)).toBe("2028-02-29"); + expect(addMonths("2026-01-31", 1)).toBe("2026-02-28"); + expect(addMonths("2026-01-31", 1, 31)).toBe("2026-02-28"); + expect(addMonths("2026-11-15", 3)).toBe("2027-02-15"); + expect(addMonths("2026-04-10", 0, "last")).toBe("2026-04-30"); + }); +}); + +describe("occurrenceDate", () => { + it("aylık: başlangıç günü korunur, ayda yoksa son gün", () => { + const r = rule({}); + expect([0, 1, 2, 3].map((i) => occurrenceDate(r, i))).toEqual([ + "2026-01-31", + "2026-02-28", + "2026-03-31", + "2026-04-30", + ]); + }); + + it("aylık son gün modu (artık yıl dahil)", () => { + const r = rule({ monthlyMode: "last", startDate: "2028-01-10" }); + expect([0, 1, 2].map((i) => occurrenceDate(r, i))).toEqual([ + "2028-01-31", + "2028-02-29", + "2028-03-31", + ]); + }); + + it("günlük, haftalık (aralıkla), yıllık (29 Şubat)", () => { + expect( + occurrenceDate(rule({ frequency: "daily", interval: 3, startDate: "2026-11-30" }), 1), + ).toBe("2026-12-03"); + expect( + occurrenceDate(rule({ frequency: "weekly", interval: 2, startDate: "2026-11-02" }), 2), + ).toBe("2026-11-30"); + const y = rule({ frequency: "yearly", startDate: "2028-02-29" }); + expect([1, 4].map((i) => occurrenceDate(y, i))).toEqual(["2029-02-28", "2032-02-29"]); + }); +}); + +describe("listOccurrences", () => { + it("tekrar sayısı, bitiş tarihi ve ufuk sınırları", () => { + const r = rule({ startDate: "2026-01-05", count: 3 }); + expect(listOccurrences(r, "2030-01-01").map((o) => o.date)).toEqual([ + "2026-01-05", + "2026-02-05", + "2026-03-05", + ]); + expect(isFinished(r, 3)).toBe(true); + const e = rule({ startDate: "2026-01-05", endDate: "2026-02-10" }); + expect(listOccurrences(e, "2030-01-01")).toHaveLength(2); + const h = rule({ startDate: "2026-01-05" }); + expect(listOccurrences(h, "2026-04-05").map((o) => o.index)).toEqual([0, 1, 2, 3]); + expect(listOccurrences(h, "2026-04-05", 2).map((o) => o.index)).toEqual([2, 3]); + expect(isFinished(h, 100)).toBe(false); + }); +}); + +describe("describeRecurrence", () => { + it("Türkçe açıklama", () => { + expect(describeRecurrence(rule({ startDate: "2026-11-03" }))).toBe("Her ay 3. gün"); + expect(describeRecurrence(rule({ monthlyMode: "last" }))).toBe("Her ayın son günü"); + expect( + describeRecurrence(rule({ frequency: "weekly", interval: 2, startDate: "2026-11-02" })), + ).toBe("2 haftada bir Pazartesi"); + expect(describeRecurrence(rule({ frequency: "yearly", startDate: "2026-03-15" }))).toBe( + "Her yıl 15 Mart", + ); + expect(describeRecurrence(rule({ frequency: "daily" }))).toBe("Her gün"); + }); +}); diff --git a/src/shared/recurrence.ts b/src/shared/recurrence.ts new file mode 100644 index 0000000..a2b837d --- /dev/null +++ b/src/shared/recurrence.ts @@ -0,0 +1,136 @@ +/** + * Düzenli işlem tekrarı (Bölüm 6.7). Saf fonksiyonlar: tarihler "YYYY-MM-DD" (İstanbul günü). + * Aylık/yıllık tekrarda gün, başlangıç tarihinin gününden alınır ve ayda yoksa ayın son günü + * kullanılır (31 Ocak → 28/29 Şubat → 31 Mart). "Ayın son günü" modu her zaman son günü verir. + */ + +export const FREQUENCIES = ["daily", "weekly", "monthly", "yearly"] as const; +export type Frequency = (typeof FREQUENCIES)[number]; +export const MONTHLY_MODES = ["day", "last"] as const; +export type MonthlyMode = (typeof MONTHLY_MODES)[number]; + +/** Planlı kayıtların üretileceği ufuk (ay). */ +export const HORIZON_MONTHS = 12; +export const MAX_OCCURRENCES = 600; + +export type Recurrence = { + frequency: Frequency; + /** Her N gün/hafta/ay/yılda bir (1–12). */ + interval: number; + monthlyMode: MonthlyMode; + startDate: string; + endDate: string | null; + /** Toplam tekrar sayısı (ör. 12) — endDate ile birlikte verilirse hangisi önce gelirse. */ + count: number | null; +}; + +const parse = (ymd: string) => { + const [y, m, d] = ymd.split("-").map(Number) as [number, number, number]; + return { y, m, d }; +}; +const fmt = (y: number, m: number, d: number) => + `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + +export function daysInMonth(y: number, m: number): number { + return new Date(Date.UTC(y, m, 0)).getUTCDate(); +} + +export function addDays(ymd: string, days: number): string { + const { y, m, d } = parse(ymd); + const t = new Date(Date.UTC(y, m - 1, d + days)); + return fmt(t.getUTCFullYear(), t.getUTCMonth() + 1, t.getUTCDate()); +} + +/** Ay ekler; gün ayda yoksa ayın son günü. `day` verilirse o gün hedeflenir. */ +export function addMonths(ymd: string, months: number, day?: number | "last"): string { + const { y, m, d } = parse(ymd); + const index = y * 12 + (m - 1) + months; + const ny = Math.floor(index / 12); + const nm = (index % 12) + 1; + const last = daysInMonth(ny, nm); + const target = day === "last" ? last : Math.min(day ?? d, last); + return fmt(ny, nm, target); +} + +/** index. tekrarın tarihi (0 = başlangıç; "son gün" modunda başlangıç ayının son günü). */ +export function occurrenceDate(r: Recurrence, index: number): string { + const n = index * r.interval; + switch (r.frequency) { + case "daily": + return addDays(r.startDate, n); + case "weekly": + return addDays(r.startDate, n * 7); + case "monthly": + return addMonths(r.startDate, n, r.monthlyMode === "last" ? "last" : parse(r.startDate).d); + case "yearly": + return addMonths(r.startDate, n * 12, parse(r.startDate).d); + } +} + +/** Ufuk tarihine (dahil) kadar, `fromIndex`ten başlayarak tekrarlar. */ +export function listOccurrences( + r: Recurrence, + until: string, + fromIndex = 0, +): { index: number; date: string }[] { + const out: { index: number; date: string }[] = []; + for (let i = fromIndex; i < MAX_OCCURRENCES; i++) { + if (r.count !== null && i >= r.count) break; + const date = occurrenceDate(r, i); + if (r.endDate && date > r.endDate) break; + if (date > until) break; + out.push({ index: i, date }); + } + return out; +} + +/** Seri bitti mi (bir sonraki tekrar yok)? */ +export function isFinished(r: Recurrence, nextIndex: number): boolean { + if (r.count !== null && nextIndex >= r.count) return true; + return Boolean(r.endDate && occurrenceDate(r, nextIndex) > r.endDate); +} + +export function horizonDate(today: string): string { + return addMonths(today, HORIZON_MONTHS); +} + +const MONTHS = [ + "Ocak", + "Şubat", + "Mart", + "Nisan", + "Mayıs", + "Haziran", + "Temmuz", + "Ağustos", + "Eylül", + "Ekim", + "Kasım", + "Aralık", +]; +const WEEKDAYS = ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"]; + +/** "Her ay 3. gün", "2 haftada bir Pazartesi", "Her ayın son günü", "Her yıl 15 Mart". */ +export function describeRecurrence( + r: Pick, +): string { + const { y, m, d } = parse(r.startDate); + const every = (unit: string) => + r.interval === 1 ? `Her ${unit}` : `${r.interval} ${unit}da bir`; + switch (r.frequency) { + case "daily": + return r.interval === 1 ? "Her gün" : `${r.interval} günde bir`; + case "weekly": { + const wd = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]!; + return r.interval === 1 ? `Her hafta ${wd}` : `${r.interval} haftada bir ${wd}`; + } + case "monthly": + if (r.monthlyMode === "last") + return r.interval === 1 ? "Her ayın son günü" : `${r.interval} ayda bir, ayın son günü`; + return r.interval === 1 ? `Her ay ${d}. gün` : `${every("ay")}, ${d}. gün`; + case "yearly": + return r.interval === 1 + ? `Her yıl ${d} ${MONTHS[m - 1]}` + : `${r.interval} yılda bir, ${d} ${MONTHS[m - 1]}`; + } +} diff --git a/src/shared/schemas/recurring.test.ts b/src/shared/schemas/recurring.test.ts new file mode 100644 index 0000000..81f0392 --- /dev/null +++ b/src/shared/schemas/recurring.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { recurringRuleFormSchema } from "./recurring"; + +/** Formun gönderdiği biçim: tüm alanlar string, seçilmeyenler boş. */ +const formValues = { + type: "expense", + amount: "1.250,00", + isEstimate: false, + accountId: "a".repeat(24), + toAccountId: "", + categoryId: "b".repeat(24), + payee: "", + description: "", + tags: "", + startDate: "2026-11-05", + frequency: "monthly", + interval: "1", + monthlyMode: "day", + endKind: "never", + count: "", + endDate: "", +}; + +describe("recurringRuleFormSchema", () => { + it("boş isteğe bağlı alanlarla geçerli; bitiş türüne göre count/endDate ayıklanır", () => { + const x = recurringRuleFormSchema.parse(formValues); + expect(x).toMatchObject({ amount: 125000, interval: 1, count: null, endDate: null }); + const c = recurringRuleFormSchema.parse({ ...formValues, endKind: "count", count: "12" }); + expect(c.count).toBe(12); + const d = recurringRuleFormSchema.parse({ + ...formValues, + endKind: "date", + endDate: "2027-01-01", + count: "5", + }); + expect(d).toMatchObject({ endDate: "2027-01-01", count: null }); + }); + + it("seçilen bitiş türünün değeri zorunlu", () => { + expect(recurringRuleFormSchema.safeParse({ ...formValues, endKind: "count" }).success).toBe( + false, + ); + expect(recurringRuleFormSchema.safeParse({ ...formValues, endKind: "date" }).success).toBe( + false, + ); + expect(recurringRuleFormSchema.safeParse({ ...formValues, interval: "13" }).success).toBe( + false, + ); + }); +}); diff --git a/src/shared/schemas/recurring.ts b/src/shared/schemas/recurring.ts new file mode 100644 index 0000000..9ae7ec7 --- /dev/null +++ b/src/shared/schemas/recurring.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { parseMoney } from "../money"; +import { FREQUENCIES, MONTHLY_MODES } from "../recurrence"; +import { normalizeTags, TRANSACTION_TYPES } from "../transactions"; + +const v = tr.validation; +const t = tr.transactions.validation; +const r = tr.recurring.validation; +const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date); +const optionalId = z + .string() + .regex(/^([a-f0-9]{24})?$/i) + .nullable() + .optional() + .transform((s) => (s ? s : null)); +const positiveMoney = z + .string() + .trim() + .transform((s, ctx) => { + const minor = parseMoney(s); + if (minor === null || minor <= 0) { + ctx.addIssue({ code: "custom", message: minor === null ? v.money : t.amountPositive }); + return z.NEVER; + } + return minor; + }); + +/** Formdan gelen boş alanlar (""), seçilmemiş isteğe bağlı değer sayılır. */ +const emptyToNull = (v: unknown) => (v === "" || v === undefined ? null : v); + +export const END_KINDS = ["never", "count", "date"] as const; + +/** Düzenli işlem kuralı formu (web + mobil). Bölme yok; onaylarken tutar düzeltilebilir. */ +export const recurringRuleFormSchema = z + .object({ + type: z.enum(TRANSACTION_TYPES), + amount: positiveMoney, + isEstimate: z.boolean().default(false), + accountId: z.string().regex(/^[a-f0-9]{24}$/i, t.selectAccount), + toAccountId: optionalId, + categoryId: optionalId, + payee: z.string().trim().max(80).default(""), + description: z.string().trim().max(500).default(""), + tags: z + .union([z.string(), z.array(z.string())]) + .default("") + .transform((x) => normalizeTags(x)), + startDate: ymd, + frequency: z.enum(FREQUENCIES), + interval: z.coerce.number().int().min(1, r.interval).max(12, r.interval).default(1), + monthlyMode: z.enum(MONTHLY_MODES).default("day"), + endKind: z.enum(END_KINDS).default("never"), + count: z.preprocess( + emptyToNull, + z.coerce.number().int().min(1, r.count).max(600, r.count).nullable().optional(), + ), + endDate: z.preprocess(emptyToNull, ymd.nullable().optional()), + }) + .superRefine((x, ctx) => { + if (x.type === "transfer") { + if (!x.toAccountId) + ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.selectToAccount }); + else if (x.toAccountId === x.accountId) + ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.sameAccount }); + } else if (!x.categoryId) { + ctx.addIssue({ code: "custom", path: ["categoryId"], message: t.selectCategory }); + } + if (x.endKind === "count" && !x.count) + ctx.addIssue({ code: "custom", path: ["count"], message: r.count }); + if (x.endKind === "date") { + if (!x.endDate) ctx.addIssue({ code: "custom", path: ["endDate"], message: v.date }); + else if (x.endDate < x.startDate) + ctx.addIssue({ code: "custom", path: ["endDate"], message: r.endBeforeStart }); + } + }) + .transform((x) => ({ + ...x, + count: x.endKind === "count" ? (x.count ?? null) : null, + endDate: x.endKind === "date" ? (x.endDate ?? null) : null, + })); + +export type RecurringRuleFormInput = z.input; +export type RecurringRuleForm = z.output; + +/** Planlı kaydı onaylama: tutar ve tarih düzeltilebilir. */ +export const confirmOccurrenceSchema = z.object({ + amount: positiveMoney, + date: ymd, +}); +export type ConfirmOccurrenceForm = z.output; + +export const SERIES_SCOPES = ["one", "following", "all"] as const; +export type SeriesScope = (typeof SERIES_SCOPES)[number];