From eaafe5dd9be45c3476d9d467e21b3c3b73e30777 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 10:55:05 +0300 Subject: [PATCH 1/5] =?UTF-8?q?docs:=20Faz=207=20kararlar=C4=B1n=C4=B1=20k?= =?UTF-8?q?aydet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/PROMPT.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/PROMPT.md b/docs/PROMPT.md index 91620a8..0137d77 100644 --- a/docs/PROMPT.md +++ b/docs/PROMPT.md @@ -318,6 +318,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em - **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce. - **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı). - **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır. +- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. --- From 04a17d27753565853c9f21175e2c3441d067e917 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 10:55:05 +0300 Subject: [PATCH 2/5] =?UTF-8?q?feat(recurring):=20d=C3=BCzenli=20i=C5=9Fle?= =?UTF-8?q?m=20kurallar=C4=B1,=20planl=C4=B1=20kay=C4=B1t=20=C3=BCretimi,?= =?UTF-8?q?=20onay/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]; From cfc2b2c974d7362f29aad20ec7b077bb8c7f6b63 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 10:55:05 +0300 Subject: [PATCH 3/5] =?UTF-8?q?feat(ui):=20planl=C4=B1=20i=C5=9Flemler=20s?= =?UTF-8?q?ayfas=C4=B1,=20kural=20formu=20ve=20vadesi=20ge=C3=A7mi=C5=9F?= =?UTF-8?q?=20uyar=C4=B1s=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/dashboard/page.tsx | 4 + src/app/(app)/planli/page.tsx | 22 + src/components/layout/nav-config.ts | 4 +- .../recurring/components/overdue-banner.tsx | 32 ++ .../recurring/components/planned-view.tsx | 508 +++++++++++++++++ .../recurring/components/rule-form.tsx | 538 ++++++++++++++++++ .../components/transaction-form.tsx | 6 +- src/i18n/tr.ts | 78 +++ 8 files changed, 1187 insertions(+), 5 deletions(-) create mode 100644 src/app/(app)/planli/page.tsx create mode 100644 src/features/recurring/components/overdue-banner.tsx create mode 100644 src/features/recurring/components/planned-view.tsx create mode 100644 src/features/recurring/components/rule-form.tsx diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index c69c6a3..7ea3637 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -3,6 +3,7 @@ import { Suspense } from "react"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; +import { OverdueBanner } from "@/features/recurring/components/overdue-banner"; import { tr } from "@/i18n/tr"; import { requireActiveHousehold } from "@/lib/dal"; import { cn } from "@/lib/utils"; @@ -59,6 +60,9 @@ export default async function DashboardPage() { title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)} description={`${household.name} · ${tr.dashboard.welcomeDescription}`} /> + + + }> diff --git a/src/app/(app)/planli/page.tsx b/src/app/(app)/planli/page.tsx new file mode 100644 index 0000000..223b7d7 --- /dev/null +++ b/src/app/(app)/planli/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { PlannedView } from "@/features/recurring/components/planned-view"; +import { getPlannedOverview } from "@/features/recurring/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.recurring.title }; + +export default async function PlannedPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const data = await getPlannedOverview(user.id, household.id); + if (!data) notFound(); + return ( + <> + + + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 25e362a..c333d29 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -27,7 +27,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 6.3; +export const CURRENT_PHASE = 7.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -36,7 +36,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 }, { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 }, { href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 }, - { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 }, + { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 }, { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 }, diff --git a/src/features/recurring/components/overdue-banner.tsx b/src/features/recurring/components/overdue-banner.tsx new file mode 100644 index 0000000..ba10dbd --- /dev/null +++ b/src/features/recurring/components/overdue-banner.tsx @@ -0,0 +1,32 @@ +import { TriangleAlert } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { tr } from "@/i18n/tr"; +import { countOverdue, ensureHorizon } from "../service"; +import { toObjectId } from "@/lib/object-id"; + +/** Dashboard: vadesi geçmiş onaylanmamış planlı işlemler uyarısı (Bölüm 6.7). */ +export async function OverdueBanner({ + userId, + householdId, +}: { + userId: string; + householdId: string; +}) { + const hid = toObjectId(householdId); + if (hid) await ensureHorizon(hid); + const n = await countOverdue(userId, householdId); + if (!n) return null; + return ( +
+ + {tr.recurring.overdueBanner(n)} + +
+ ); +} diff --git a/src/features/recurring/components/planned-view.tsx b/src/features/recurring/components/planned-view.tsx new file mode 100644 index 0000000..adc0326 --- /dev/null +++ b/src/features/recurring/components/planned-view.tsx @@ -0,0 +1,508 @@ +"use client"; + +import { + CalendarClock, + Check, + EllipsisVertical, + Loader2, + Pencil, + Plus, + SkipForward, + Trash2, + Repeat, +} from "lucide-react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { useQuickAdd } from "@/components/layout/quick-add"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardHeader } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { getTransactionAction } from "@/features/transactions/actions"; +import { + Amount, + RowIcon, + rowSubtitle, + rowTitle, +} from "@/features/transactions/components/transaction-parts"; +import { tr } from "@/i18n/tr"; +import { todayIstanbul } from "@/shared/dates"; +import { formatDate, formatMoney } from "@/shared/format"; +import { formatMoneyInput } from "@/shared/money"; +import { addDays } from "@/shared/recurrence"; +import { + confirmOccurrenceAction, + deleteRuleAction, + getRuleFormAction, + postponeOccurrenceAction, + skipOccurrenceAction, +} from "../actions"; +import type { PlannedItem, PlannedOverview, RuleView } from "../service"; +import { RuleFormDialog, type RuleFormMode } from "./rule-form"; + +const t = tr.recurring; +const dateLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`); + +type Pending = + | { kind: "confirm"; item: PlannedItem } + | { kind: "postpone"; item: PlannedItem } + | { kind: "scope"; item: PlannedItem } + | { kind: "delete"; ruleId: string; item?: PlannedItem } + | null; + +export function PlannedView({ data, canWrite }: { data: PlannedOverview; canWrite: boolean }) { + const [tab, setTab] = useState(data.overdue.length ? "overdue" : "upcoming"); + const [pending, setPending] = useState(null); + const [ruleForm, setRuleForm] = useState(null); + const [, startTransition] = useTransition(); + const { openTransactionForm } = useQuickAdd(); + + function skip(item: PlannedItem) { + startTransition(async () => { + const r = await skipOccurrenceAction(item.id); + if (r.ok) toast.success(t.toast.skipped); + else toast.error(r.error); + }); + } + + async function editScope(item: PlannedItem, scope: "one" | "following" | "all") { + setPending(null); + if (scope === "one" || !item.recurringRuleId) { + const d = await getTransactionAction(item.id); + if (d.ok) openTransactionForm({ kind: "edit", transaction: d.data }); + else toast.error(d.error); + return; + } + const rule = await getRuleFormAction(item.recurringRuleId); + if (!rule.ok) return void toast.error(rule.error); + const [pos, total] = (item.seriesPosition ?? "").split("/").map(Number); + setRuleForm({ + kind: "edit", + rule: rule.data, + scope, + fromTransactionId: scope === "following" ? item.id : undefined, + fromDate: scope === "following" ? item.date : undefined, + remainingCount: pos && total ? total - pos + 1 : null, + }); + } + + async function editRule(rule: RuleView) { + const r = await getRuleFormAction(rule.id); + if (r.ok) setRuleForm({ kind: "edit", rule: r.data, scope: "all" }); + else toast.error(r.error); + } + + const itemList = (items: PlannedItem[], empty: string) => + items.length === 0 ? ( + + + {empty} + + + ) : ( +
    + {items.map((item) => ( +
  • + + + + {rowTitle(item)} + {item.seriesPosition ? ( + + {item.seriesPosition} + + ) : null} + {item.isEstimate ? ( + + {t.estimate} + + ) : null} + + + {/* Mobilde rozetler alt satırda (başlık daralmasın). */} + + {item.seriesPosition ? `${item.seriesPosition} · ` : ""} + {item.isEstimate ? `${t.estimate} · ` : ""} + + {dateLabel(item.date)} · {rowSubtitle(item)} + + + + {canWrite ? ( + + + + + + + + setPending({ kind: "postpone", item })}> + + {t.actions.postpone} + + skip(item)}> + + {t.actions.skip} + + + item.recurringRuleId + ? setPending({ kind: "scope", item }) + : void editScope(item, "one") + } + > + + {t.actions.edit} + + {item.recurringRuleId ? ( + <> + + + setPending({ kind: "delete", ruleId: item.recurringRuleId!, item }) + } + > + + {t.actions.delete} + + + ) : null} + + + + ) : null} +
  • + ))} +
+ ); + + return ( +
+ {canWrite ? ( +
+ +
+ ) : null} + + + + {t.tabs.overdue} + {data.overdue.length ? ( + + {data.overdue.length} + + ) : null} + + {t.tabs.upcoming} + {t.tabs.rules} + + + {itemList(data.overdue, t.empty.overdue)} + + + {itemList(data.upcoming, t.empty.upcoming)} + + + {data.rules.length === 0 ? ( + + + {t.empty.rules} + + + ) : ( +
    + {data.rules.map((rule) => ( +
  • + + + + + + {rule.title} + {rule.isEstimate ? {t.estimate} : null} + + + {rule.recurrence} ·{" "} + {rule.toAccount ? `${rule.account} → ${rule.toAccount}` : rule.account} + {rule.nextDate ? ` · ${t.next(dateLabel(rule.nextDate))}` : ""} + {rule.remaining !== null + ? ` · ${t.remaining(rule.remaining)}` + : ` · ${t.endless}`} + + + + {canWrite ? ( + + + + + + void editRule(rule)}> + + {t.actions.edit} + + setPending({ kind: "delete", ruleId: rule.id })} + > + + {t.actions.delete} + + + + ) : null} +
  • + ))} +
+ )} +
+
+ + setPending(null)} + /> + setPending(null)} + /> + setPending(null)} + onPick={(item, scope) => void editScope(item, scope)} + /> + setPending(null)} + /> + !o && setRuleForm(null)} /> +
+ ); +} + +function ConfirmDialog({ item, onClose }: { item: PlannedItem | null; onClose: () => void }) { + return item ? : null; +} + +function ConfirmForm({ item, onClose }: { item: PlannedItem; onClose: () => void }) { + const today = todayIstanbul(); + const [amount, setAmount] = useState(formatMoneyInput(item.amountMinor)); + // Gelecekteki kaydı erken onaylarken bugünün tarihi; geçmiştekini kendi tarihiyle. + const [date, setDate] = useState(item.date > today ? today : item.date); + const [errors, setErrors] = useState>({}); + const [busy, setBusy] = useState(false); + + async function submit() { + setBusy(true); + const r = await confirmOccurrenceAction(item.id, { amount, date }); + setBusy(false); + if (r.ok) { + toast.success(t.toast.confirmed); + onClose(); + } else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error }); + } + + return ( + !o && onClose()} + title={`${t.confirmTitle}: ${rowTitle(item)}`} + description={t.confirmDescription} + footer={ + <> + + + + } + > + + + {tr.transactions.fields.amount} + + {errors.amount ? {errors.amount} : null} + + + {tr.transactions.fields.date} + setDate(e.target.value)} + /> + {errors.date ? {errors.date} : null} + + {item.isEstimate ? ( +

+ {t.estimate}: {formatMoney(item.amountMinor)} +

+ ) : null} + {errors.root ? {errors.root} : null} +
+
+ ); +} + +function PostponeDialog({ item, onClose }: { item: PlannedItem | null; onClose: () => void }) { + return item ? : null; +} + +function PostponeForm({ item, onClose }: { item: PlannedItem; onClose: () => void }) { + const base = item.date > todayIstanbul() ? item.date : todayIstanbul(); + const [date, setDate] = useState(addDays(base, 7)); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit() { + setBusy(true); + const r = await postponeOccurrenceAction(item.id, date); + setBusy(false); + if (r.ok) { + toast.success(t.toast.postponed); + onClose(); + } else setError(r.fieldErrors?.date ?? r.error); + } + + return ( + !o && onClose()} + title={`${t.postponeTitle}: ${rowTitle(item)}`} + footer={ + <> + + + + } + > + + {t.postponeDate} + setDate(e.target.value)} + /> + {error ? {error} : null} + + + ); +} + +function ScopeDialog({ + item, + onClose, + onPick, +}: { + item: PlannedItem | null; + onClose: () => void; + onPick: (item: PlannedItem, scope: "one" | "following" | "all") => void; +}) { + return ( + !o && onClose()} + title={t.scopeTitle} + > +
+ {(["one", "following", "all"] as const).map((scope) => ( + + ))} +
+
+ ); +} + +function DeleteDialog({ + target, + onClose, +}: { + target: { ruleId: string; item?: PlannedItem } | null; + onClose: () => void; +}) { + const [busy, setBusy] = useState(false); + async function run(scope: "all" | "following") { + if (!target) return; + setBusy(true); + const r = await deleteRuleAction(target.ruleId, scope, target.item?.id); + setBusy(false); + if (r.ok) { + toast.success(t.toast.deleted); + onClose(); + } else toast.error(r.error); + } + return ( + !o && onClose()}> + + + {t.deleteTitle} + {t.deleteDescription} + + + {tr.common.cancel} + {target?.item ? ( + + ) : null} + + + + + ); +} diff --git a/src/features/recurring/components/rule-form.tsx b/src/features/recurring/components/rule-form.tsx new file mode 100644 index 0000000..bdf5987 --- /dev/null +++ b/src/features/recurring/components/rule-form.tsx @@ -0,0 +1,538 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Controller, useForm, useWatch, type Resolver } from "react-hook-form"; +import { toast } from "sonner"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { getTransactionFormOptionsAction } from "@/features/transactions/actions"; +import { + AccountSelect, + CategorySelect, + PayeeInput, +} from "@/features/transactions/components/transaction-form"; +import type { TransactionFormOptions } from "@/features/transactions/service"; +import { tr } from "@/i18n/tr"; +import { todayIstanbul } from "@/shared/dates"; +import { formatDate } from "@/shared/format"; +import { formatMoneyInput } from "@/shared/money"; +import { + describeRecurrence, + FREQUENCIES, + listOccurrences, + MONTHLY_MODES, + type Frequency, + type MonthlyMode, +} from "@/shared/recurrence"; +import { + END_KINDS, + recurringRuleFormSchema, + type RecurringRuleForm, +} from "@/shared/schemas/recurring"; +import { TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions"; +import { createRuleAction, updateRuleAction } from "../actions"; +import type { RuleFormValues } from "../service"; + +const t = tr.recurring; +const tf = tr.transactions.fields; + +export type RuleFormMode = + | { kind: "create" } + | { + kind: "edit"; + rule: RuleFormValues; + scope: "all" | "following"; + /** "Bu ve sonrakiler" için başlangıç tekrarı ve onun tarihi / kalan tekrar sayısı. */ + fromTransactionId?: string; + fromDate?: string; + remainingCount?: number | null; + }; + +type FormValues = { + type: TransactionType; + amount: string; + isEstimate: boolean; + accountId: string; + toAccountId: string; + categoryId: string; + payee: string; + description: string; + tags: string; + startDate: string; + frequency: Frequency; + interval: string; + monthlyMode: MonthlyMode; + endKind: (typeof END_KINDS)[number]; + count: string; + endDate: string; +}; + +function toValues(mode: RuleFormMode, options: TransactionFormOptions | null): FormValues { + if (mode.kind === "create") { + return { + type: "expense", + amount: "", + isEstimate: false, + accountId: options?.accounts[0]?.id ?? "", + toAccountId: "", + categoryId: "", + payee: "", + description: "", + tags: "", + startDate: todayIstanbul(), + frequency: "monthly", + interval: "1", + monthlyMode: "day", + endKind: "never", + count: "", + endDate: "", + }; + } + const r = mode.rule; + const following = mode.scope === "following" && mode.fromDate; + const count = following ? (mode.remainingCount ?? null) : r.count; + return { + type: r.type, + amount: formatMoneyInput(r.amountMinor), + isEstimate: r.isEstimate, + accountId: r.accountId, + toAccountId: r.toAccountId ?? "", + categoryId: r.categoryId ?? "", + payee: r.payee, + description: r.description, + tags: r.tags.join(", "), + startDate: following ? mode.fromDate! : r.startDate, + frequency: r.frequency, + interval: String(r.interval), + monthlyMode: r.monthlyMode, + endKind: count ? "count" : r.endDate ? "date" : "never", + count: count ? String(count) : "", + endDate: r.endDate ?? "", + }; +} + +/** Düzenli işlem kuralı formu (oluştur / tümünü düzenle / bu ve sonrakileri düzenle). */ +export function RuleFormDialog({ + mode, + onOpenChange, +}: { + mode: RuleFormMode | null; + onOpenChange: (open: boolean) => void; +}) { + const [options, setOptions] = useState(null); + const [loadError, setLoadError] = useState(null); + const form = useForm({ + resolver: zodResolver(recurringRuleFormSchema) as unknown as Resolver< + FormValues, + unknown, + RecurringRuleForm + >, + defaultValues: toValues({ kind: "create" }, null), + }); + + useEffect(() => { + if (!mode) return; + let cancelled = false; + getTransactionFormOptionsAction().then((res) => { + if (cancelled) return; + if (!res.ok) return setLoadError(res.error); + setLoadError(null); + setOptions(res.data); + form.reset(toValues(mode, res.data)); + }); + return () => { + cancelled = true; + }; + }, [mode, form]); + + const w = useWatch({ control: form.control }); + const type = w.type ?? "expense"; + const categories = useMemo(() => { + const kind = type === "income" ? "income" : "expense"; + return (options?.categories ?? []).filter((c) => c.kind === kind); + }, [options, type]); + + // Önizleme: açıklama ve ilk 3 tarih (istemcide aynı saf fonksiyonla). + const preview = useMemo(() => { + const interval = Math.min(12, Math.max(1, Number(w.interval) || 1)); + const r = { + frequency: w.frequency ?? "monthly", + interval, + monthlyMode: w.monthlyMode ?? "day", + startDate: w.startDate ?? todayIstanbul(), + endDate: w.endKind === "date" && w.endDate ? w.endDate : null, + count: w.endKind === "count" && Number(w.count) > 0 ? Number(w.count) : null, + } as const; + if (!/^\d{4}-\d{2}-\d{2}$/.test(r.startDate)) return null; + return { + text: describeRecurrence(r), + dates: listOccurrences(r, "9999-12-31") + .slice(0, 3) + .map((o) => o.date), + }; + }, [w]); + + function changeType(next: TransactionType) { + form.setValue("type", next); + form.setValue("categoryId", ""); + if (next !== "transfer") form.setValue("toAccountId", ""); + form.clearErrors(); + } + + async function onSubmit() { + if (!mode) return; + const values = form.getValues(); + const payload = { + ...values, + toAccountId: values.type === "transfer" ? values.toAccountId : "", + categoryId: values.type === "transfer" ? "" : values.categoryId, + count: values.endKind === "count" ? values.count : null, + endDate: values.endKind === "date" ? values.endDate : null, + }; + const result = + mode.kind === "edit" + ? await updateRuleAction(mode.rule.id, payload, mode.scope, mode.fromTransactionId) + : await createRuleAction(payload); + if (result.ok) { + toast.success(mode.kind === "edit" ? t.toast.updated : t.toast.created); + onOpenChange(false); + return; + } + for (const [name, message] of Object.entries(result.fieldErrors ?? {})) { + if (message) form.setError(name as keyof FormValues, { message }); + } + if (!result.fieldErrors) form.setError("root", { message: result.error }); + } + + const loading = Boolean(mode) && !options && !loadError; + const errors = form.formState.errors; + const title = mode?.kind === "edit" ? `${t.editTitle} · ${t.scopes[mode.scope]}` : t.createTitle; + + return ( + + + + + ) + } + > + {loadError ? ( +

{loadError}

+ ) : loading || !options ? ( +
+ + + +
+ ) : ( +
+ + changeType(v as TransactionType)}> + + {TRANSACTION_TYPES.map((x) => ( + + {tr.transactions.types[x]} + + ))} + + + + ( + + {tf.amount} + + {fieldState.invalid ? : null} + + )} + /> + ( + + )} + /> + + ( + + + {type === "transfer" ? tf.fromAccount : tf.account} + + + {fieldState.invalid ? : null} + + )} + /> + {type === "transfer" ? ( + ( + + {tf.toAccount} + + {fieldState.invalid ? : null} + + )} + /> + ) : ( + ( + + {tf.category} + + {fieldState.invalid ? : null} + + )} + /> + )} + ( + + {tf.payee} + {}} /> + + )} + /> + ( + + {tf.description} + + + )} + /> + +
+ ( + + {t.fields.startDate} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.fields.frequency} + + + )} + /> +
+
+ ( + + + {t.fields.interval} ({t.units[w.frequency ?? "monthly"]}) + + + {fieldState.invalid ? : null} + + )} + /> + {w.frequency === "monthly" ? ( + ( + + {t.fields.monthlyMode} + + + )} + /> + ) : null} +
+
+ ( + + {t.fields.end} + + + )} + /> + {w.endKind === "count" ? ( + ( + + {t.fields.count} + + {fieldState.invalid ? : null} + + )} + /> + ) : w.endKind === "date" ? ( + ( + + {t.fields.endDate} + + {fieldState.invalid ? : null} + + )} + /> + ) : null} +
+ {preview ? ( + + {preview.text} · {t.fields.preview}:{" "} + {preview.dates.map((d) => formatDate(`${d}T12:00:00+03:00`)).join(", ")} + + ) : null} + {errors.root ? {errors.root.message} : null} +
+
+ )} +
+ ); +} diff --git a/src/features/transactions/components/transaction-form.tsx b/src/features/transactions/components/transaction-form.tsx index 15849c1..72ed56c 100644 --- a/src/features/transactions/components/transaction-form.tsx +++ b/src/features/transactions/components/transaction-form.tsx @@ -101,7 +101,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n }; } -function AccountSelect({ +export function AccountSelect({ id, value, onChange, @@ -148,7 +148,7 @@ function AccountSelect({ ); } -function CategorySelect({ +export function CategorySelect({ id, value, onChange, @@ -199,7 +199,7 @@ function CategorySelect({ } /** Satıcı alanı: yazdıkça öneri; seçilince son kullanılan kategori/hesap önerilir. */ -function PayeeInput({ +export function PayeeInput({ value, onChange, onPick, diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 2178555..b937c78 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -352,6 +352,84 @@ export const tr = { noAccounts: "İşlem girmek için önce bir hesap gerekli.", noAccountsLink: "Hesaplara git", }, + recurring: { + title: "Planlı işlemler", + description: + "Düzenli ve planlı gelir/giderler. Planlı kayıtlar bakiyeyi etkilemez; onaylanınca gerçekleşir.", + newRule: "Yeni düzenli işlem", + createTitle: "Yeni düzenli işlem", + editTitle: "Düzenli işlemi düzenle", + tabs: { upcoming: "Yaklaşan", overdue: "Vadesi geçmiş", rules: "Kurallar" }, + fields: { + estimate: "Tutar tahmini (ör. fatura); onaylarken düzeltilir", + startDate: "İlk tarih", + frequency: "Sıklık", + interval: "Kaç birimde bir", + monthlyMode: "Ayın hangi günü", + end: "Bitiş", + count: "Tekrar sayısı", + endDate: "Bitiş tarihi", + preview: "İlk tarihler", + }, + frequencies: { daily: "Günlük", weekly: "Haftalık", monthly: "Aylık", yearly: "Yıllık" }, + units: { daily: "gün", weekly: "hafta", monthly: "ay", yearly: "yıl" }, + monthlyModes: { day: "İlk tarihin günü", last: "Ayın son günü" }, + endKinds: { never: "Bitiş yok", count: "Tekrar sayısı", date: "Bitiş tarihi" }, + actions: { + confirm: "Onayla", + skip: "Atla", + postpone: "Ertele", + edit: "Düzenle", + delete: "Sil", + more: "Diğer işlemler", + }, + confirmTitle: "İşlemi onayla", + confirmDescription: "Gerçekleşen tutarı ve tarihi kontrol edin; onaylanınca bakiyeye yansır.", + postponeTitle: "Ertele", + postponeDate: "Yeni tarih", + scopeTitle: "Hangi kayıtlar değişsin?", + scopes: { one: "Yalnızca bu", following: "Bu ve sonrakiler", all: "Tümü" }, + scopeHints: { + one: "Yalnızca bu planlı kayıt değişir; seri aynı kalır.", + following: "Seri bu tarihten itibaren yeni değerlerle devam eder.", + all: "Onaylanmamış tüm planlı kayıtlar yeni değerlerle yeniden oluşturulur. Gerçekleşmiş kayıtlar değişmez.", + }, + deleteTitle: "Düzenli işlem silinsin mi?", + deleteDescription: + "Gerçekleşmiş kayıtlar korunur; yalnızca onaylanmamış planlı kayıtlar silinir.", + deleteScopes: { following: "Bu ve sonrakileri sil", all: "Tüm seriyi sil" }, + estimate: "Tahmini", + next: (date: string) => `Sıradaki: ${date}`, + remaining: (n: number) => `${n} tekrar kaldı`, + endless: "Süresiz", + series: (pos: string) => `${pos}`, + empty: { + upcoming: "Önümüzdeki 60 günde planlı işlem yok.", + overdue: "Vadesi geçmiş planlı işlem yok.", + rules: "Henüz düzenli işlem yok. Kira, faturalar ve abonelikler için ekleyin.", + }, + overdueBanner: (n: number) => + `${n} planlı işlemin tarihi geçti; onaylayın, erteleyin veya atlayın.`, + overdueLink: "Planlı işlemlere git", + upcomingTitle: "Yaklaşan planlı işlemler", + toast: { + created: "Düzenli işlem kaydedildi.", + updated: "Düzenli işlem güncellendi.", + deleted: "Düzenli işlem silindi.", + confirmed: "İşlem onaylandı.", + skipped: "Bu tekrar atlandı.", + postponed: "İşlem ertelendi.", + }, + validation: { + interval: "1 ile 12 arasında olmalı", + count: "1 ile 600 arasında bir sayı girin", + endBeforeStart: "Bitiş tarihi ilk tarihten önce olamaz", + }, + errors: { + splitAmount: "Bölünmüş planlı işlemin tutarı değiştirilemez.", + postponeDate: "Yeni tarih mevcut tarihten sonra olmalı.", + }, + }, attachments: { title: "Ekler", add: "Ek ekle", From 5bd2c372bff2079e2e262395fa20040dcd52e94a Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 10:55:05 +0300 Subject: [PATCH 4/5] =?UTF-8?q?feat(seed):=20=C3=B6rnek=20d=C3=BCzenli=20i?= =?UTF-8?q?=C5=9Flemler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/seed/recurring.ts | 201 +++++++++++++++++++++++++++++++++++ scripts/seed/run.int.test.ts | 1 + scripts/seed/run.ts | 7 ++ 3 files changed, 209 insertions(+) create mode 100644 scripts/seed/recurring.ts diff --git a/scripts/seed/recurring.ts b/scripts/seed/recurring.ts new file mode 100644 index 0000000..33ca568 --- /dev/null +++ b/scripts/seed/recurring.ts @@ -0,0 +1,201 @@ +import type { Types } from "mongoose"; +import { createRule } from "@/features/recurring/service"; +import { AccountModel } from "@/models/account"; +import { CategoryModel } from "@/models/category"; +import { RecurringRuleModel } from "@/models/recurring-rule"; +import { todayIstanbul } from "@/shared/dates"; +import { addDays, addMonths } from "@/shared/recurrence"; +import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring"; +import { formatMoneyInput } from "@/shared/money"; + +/** + * Örnek düzenli işlemler (Faz 7a). Seed işlemleri bugüne kadarki günleri doldurur; kurallar bir + * sonraki gerçekleşme gününden başlar (çakışma yok). "Spor salonu" kasıtlı olarak 3 gün önce + * başlar: vadesi geçmiş planlı kayıt örneği. İdempotent (`seedRef`). + */ +type SeedRule = { + ref: string; + by: string; + type: "income" | "expense" | "transfer"; + amountMinor: number; + account: string; + category?: string; + payee: string; + /** Ayın günü (bir sonraki gerçekleşme) veya bugüne göre gün farkı. */ + day?: number; + offsetDays?: number; + isEstimate?: boolean; + count?: number; +}; + +const RULES: Record = { + "Çakır Ailesi": [ + { + ref: "maas", + by: "admin", + type: "income", + amountMinor: 52_000_00, + account: "Ziraat Maaş", + category: "maas", + payee: "Anadolu Yazılım A.Ş.", + day: 15, + }, + { + ref: "kira", + by: "admin", + type: "expense", + amountMinor: 17_500_00, + account: "Ziraat Maaş", + category: "kira", + payee: "Ev sahibi", + day: 3, + }, + { + ref: "aidat", + by: "admin", + type: "expense", + amountMinor: 1_250_00, + account: "Ziraat Maaş", + category: "aidat", + payee: "Site yönetimi", + day: 5, + }, + { + ref: "elektrik", + by: "ayse", + type: "expense", + amountMinor: 1_200_00, + account: "Ziraat Maaş", + category: "faturalar.elektrik", + payee: "Enerjisa", + day: 10, + isEstimate: true, + }, + { + ref: "su", + by: "ayse", + type: "expense", + amountMinor: 450_00, + account: "Ziraat Maaş", + category: "faturalar.su", + payee: "İSKİ", + day: 12, + isEstimate: true, + }, + { + ref: "dogalgaz", + by: "ayse", + type: "expense", + amountMinor: 2_500_00, + account: "Ziraat Maaş", + category: "faturalar.dogalgaz", + payee: "İGDAŞ", + day: 14, + isEstimate: true, + }, + { + ref: "internet", + by: "admin", + type: "expense", + amountMinor: 499_90, + account: "Garanti Bonus", + category: "faturalar.internet", + payee: "Türk Telekom", + day: 20, + }, + { + ref: "netflix", + by: "admin", + type: "expense", + amountMinor: 229_99, + account: "Garanti Bonus", + category: "abonelikler", + payee: "Netflix", + day: 8, + }, + { + ref: "spor", + by: "emre", + type: "expense", + amountMinor: 1_250_00, + account: "Garanti Bonus", + category: "eglence", + payee: "Spor salonu", + offsetDays: -3, + count: 12, + }, + ], + Annemler: [ + { + ref: "emekli", + by: "fatma", + type: "income", + amountMinor: 24_350_00, + account: "Vakıfbank Emekli", + category: "maas", + payee: "SGK", + day: 17, + }, + { + ref: "elektrik", + by: "fatma", + type: "expense", + amountMinor: 700_00, + account: "Vakıfbank Emekli", + category: "faturalar.elektrik", + payee: "Enerjisa", + day: 11, + isEstimate: true, + }, + ], +}; + +/** Bugünden sonraki ilk "ayın N. günü". */ +function nextMonthDay(today: string, day: number): string { + const thisMonth = addMonths(today, 0, day); + return thisMonth > today ? thisMonth : addMonths(today, 1, day); +} + +export async function seedRecurring( + householdIds: Map, + userIds: Map, +): Promise { + const today = todayIstanbul(); + let created = 0; + for (const [household, rules] of Object.entries(RULES)) { + const householdId = householdIds.get(household)!; + const [accounts, categories] = await Promise.all([ + AccountModel.find({ householdId }).select({ name: 1 }).lean(), + CategoryModel.find({ householdId, defaultKey: { $ne: null } }) + .select({ defaultKey: 1 }) + .lean(), + ]); + for (const r of rules) { + const seedRef = `rule:${r.ref}`; + if (await RecurringRuleModel.exists({ householdId, seedRef })) continue; + const input: RecurringRuleFormInput = { + type: r.type, + amount: formatMoneyInput(r.amountMinor), + isEstimate: r.isEstimate ?? false, + accountId: String(accounts.find((a) => a.name === r.account)!._id), + categoryId: r.category + ? String(categories.find((c) => c.defaultKey === r.category)!._id) + : null, + payee: r.payee, + startDate: r.day ? nextMonthDay(today, r.day) : addDays(today, r.offsetDays ?? 0), + frequency: "monthly", + endKind: r.count ? "count" : "never", + count: r.count ?? null, + }; + const result = await createRule( + userIds.get(r.by)!, + String(householdId), + recurringRuleFormSchema.parse(input), + ); + if (!result.ok) throw new Error(`Seed kuralı oluşturulamadı (${seedRef}): ${result.error}`); + await RecurringRuleModel.updateOne({ _id: result.data.id }, { $set: { seedRef } }); + created++; + } + } + return created; +} diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index b720dc8..26e97a3 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -45,6 +45,7 @@ describe("seed", () => { categories: 0, accounts: 0, transactions: 0, + recurringRules: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 1bb65f7..348f7e2 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -10,6 +10,7 @@ import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import { storedEmail } from "@/shared/account-email"; import { runMigrations } from "../migrations"; +import { seedRecurring } from "./recurring"; import { seedTransactions } from "./transactions"; import { ADMIN_USERNAME, @@ -43,6 +44,7 @@ export type SeedSummary = { categories: number; accounts: number; transactions: number; + recurringRules: number; }; accounts: SeedAccountRow[]; }; @@ -58,6 +60,7 @@ const DOMAIN_COLLECTIONS = [ "payees", "attachments", "reconciliations", + "recurringRules", ]; type AuthApi = ReturnType["api"]; @@ -107,6 +110,7 @@ export async function runSeed({ categories: 0, accounts: 0, transactions: 0, + recurringRules: 0, }, accounts: [], }; @@ -272,6 +276,9 @@ export async function runSeed({ const userIdMap = new Map([...ids.entries()]); summary.created.transactions = await seedTransactions(householdIds, userIdMap); if (summary.created.transactions) log(`✓ Örnek işlemler: ${summary.created.transactions}`); + summary.created.recurringRules = await seedRecurring(householdIds, userIdMap); + if (summary.created.recurringRules) + log(`✓ Örnek düzenli işlemler: ${summary.created.recurringRules}`); // 7) Geliştirme hesapları tablosu const all = [ From 8c4771908cadfc4dd5b8fb34d647b5e001384ea5 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 10:55:05 +0300 Subject: [PATCH 5/5] chore: CHANGELOG (Faz 7a) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d151ce3..e36430b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +Faz 7 (alt fazlar halinde; `v0.7.0` Faz 7b sonunda). + +### Eklendi — Faz 7a: planlı ve düzenli işlemler + +- Düzenli işlem kuralları (`recurringRules`): gelir, gider, transfer; sabit veya tahmini tutar; günlük/haftalık/aylık/yıllık, "her N birimde bir", ayın N. günü (ayda yoksa son gün) veya ayın son günü; bitiş yok, tekrar sayısı (ör. 12) veya bitiş tarihi. Formda Türkçe tekrar açıklaması ve ilk tarihlerin önizlemesi. +- Her tekrar için `planned` durumunda planlı kayıt; bitiş tarihsiz kurallarda 12 ay ileriye kadar. Ufuk sayfa okunurken ilerletilir (zamanlanmış görev gerekmez); üretim idempotent (`recurringRuleId + occurrenceIndex` benzersiz). Planlı kayıtlar bakiyeyi etkilemez. +- `/planli`: vadesi geçmiş, yaklaşan (60 gün) ve kurallar sekmeleri; "Onayla" (tutar ve tarih düzeltilebilir), "Ertele", "Atla"; seri pozisyonu ("3/12") ve "Tahmini" rozetleri. +- Seri düzenleme: "Yalnızca bu" (kayıt seriden ayrılır), "Bu ve sonrakiler" (seri bölünür), "Tümü" (onaylanmamış planlı kayıtlar yeniden üretilir; gerçekleşen, atlanan ve ayrılmış kayıtlar korunur). Silmede "bu ve sonrakiler" / "tüm seri"; gerçekleşmiş kayıtlar silinmez. +- Dashboard'da vadesi geçmiş planlı işlem uyarısı. +- Seed: 11 örnek düzenli işlem (maaş, kira, aidat, tahmini faturalar, abonelikler, emekli maaşı; vadesi geçmiş örnek olarak 12 taksitli spor salonu). +- `005` migration'ı: `recurringRules` index'leri, işlemlerde tekrar alanları. Denetim kaydı: `recurring.delete`. +- Testler: tekrar tarihi hesabı (ay sonu, artık yıl, aralık, sayı/bitiş/ufuk) ve form şeması (birim); üretim, onay/atla/ertele, seri düzenleme ve silme, bakiye etkisizliği, yetki ve gizli hesap izolasyonu (entegrasyon). + +### Değişti — Faz 7a + +- İşlem listesi varsayılan olarak planlı kayıtları göstermez (durum filtresiyle görülebilir); özet toplamları seçili duruma göre hesaplanır. + ## [0.6.0] - 2026-11-05 Faz 6: hesaplar, kategoriler, işlemler, ek dosyalar ve mutabakat (6a, 6b, 6c alt fazları).