feat(recurring): düzenli işlem kuralları, planlı kayıt üretimi, onay/atla/ertele ve seri düzenleme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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<string, string> = {};
|
||||
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<ActionResult<{ id: string }>> {
|
||||
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<ActionResult<{ id: string }>> {
|
||||
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<ActionResult<{ removed: number }>> {
|
||||
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<ActionResult<RuleFormValues>> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import {
|
||||
listTransactions,
|
||||
updateTransaction,
|
||||
getTransaction,
|
||||
} from "@/features/transactions/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { RecurringRuleModel } from "@/models/recurring-rule";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring";
|
||||
import { transactionFilterSchema } from "@/shared/schemas/transaction";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
confirmOccurrence,
|
||||
createRule,
|
||||
deleteRule,
|
||||
ensureHorizon,
|
||||
getPlannedOverview,
|
||||
postponeOccurrence,
|
||||
skipOccurrence,
|
||||
updateRule,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
const today = todayIstanbul();
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
emre = ids.user("emre");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const acc = (name: string) =>
|
||||
AccountModel.findOne({ householdId: cakir, name })
|
||||
.lean()
|
||||
.then((a) => String(a!._id));
|
||||
const cat = (key: string) =>
|
||||
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
|
||||
.lean()
|
||||
.then((c) => String(c!._id));
|
||||
|
||||
async function form(over: Partial<RecurringRuleFormInput> = {}) {
|
||||
return recurringRuleFormSchema.parse({
|
||||
type: "expense",
|
||||
amount: "1.000,00",
|
||||
accountId: await acc("Ziraat Maaş"),
|
||||
categoryId: await cat("faturalar.elektrik"),
|
||||
payee: "Elektrik",
|
||||
startDate: addDays(today, -40),
|
||||
frequency: "monthly",
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
const occurrences = (ruleId: string) =>
|
||||
TransactionModel.find({ recurringRuleId: ruleId }).sort({ occurrenceIndex: 1 }).lean();
|
||||
|
||||
async function balance(accountId: string) {
|
||||
const list = await listAccounts(ayse, cakir);
|
||||
return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
|
||||
}
|
||||
|
||||
describe("kural ve planlı kayıtlar", () => {
|
||||
let ruleId = "";
|
||||
|
||||
it("kural 12 ay ileriye planlı kayıt üretir; planlı kayıtlar bakiyeyi etkilemez", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const before = await balance(ziraat);
|
||||
const r = await createRule(ayse, cakir, await form({ isEstimate: true }));
|
||||
expect(r.ok).toBe(true);
|
||||
ruleId = r.ok ? r.data.id : "";
|
||||
const occ = await occurrences(ruleId);
|
||||
// Başlangıç 40 gün önce: 2 geçmiş + 12 ay ileri (13 ya da 14 kayıt, ay uzunluğuna göre).
|
||||
expect(occ.length).toBeGreaterThanOrEqual(13);
|
||||
expect(occ.every((o) => o.status === "planned" && o.isEstimate)).toBe(true);
|
||||
expect(await balance(ziraat)).toBe(before);
|
||||
expect(await ensureHorizon((await RecurringRuleModel.findById(ruleId))!.householdId)).toBe(0);
|
||||
});
|
||||
|
||||
it("genel bakış: vadesi geçmiş ve yaklaşan; işlem listesi planlıları varsayılan göstermez", async () => {
|
||||
const o = (await getPlannedOverview(ayse, cakir))!;
|
||||
const mine = o.overdue.filter((x) => x.recurringRuleId === ruleId);
|
||||
expect(mine.length).toBeGreaterThanOrEqual(1);
|
||||
expect(o.upcoming.every((x) => x.date >= today)).toBe(true);
|
||||
expect(o.rules.find((x) => x.id === ruleId)).toMatchObject({
|
||||
recurrence: expect.stringContaining("Her ay"),
|
||||
remaining: null,
|
||||
});
|
||||
|
||||
const list = await listTransactions(ayse, cakir, transactionFilterSchema.parse({}));
|
||||
expect(list!.items.some((i) => i.status === "planned")).toBe(false);
|
||||
const planned = await listTransactions(
|
||||
ayse,
|
||||
cakir,
|
||||
transactionFilterSchema.parse({ durum: "planned" }),
|
||||
);
|
||||
expect(planned!.total).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("onayla (tutar/tarih düzeltilerek) bakiyeye yansır; atla iptal eder", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const before = await balance(ziraat);
|
||||
const [first, second] = await occurrences(ruleId);
|
||||
const c = await confirmOccurrence(ayse, cakir, String(first!._id), {
|
||||
amount: 1_234_56,
|
||||
date: today,
|
||||
});
|
||||
expect(c.ok).toBe(true);
|
||||
const posted = (await TransactionModel.findById(first!._id).lean())!;
|
||||
expect(posted).toMatchObject({ status: "posted", amountMinor: 1_234_56, isEstimate: false });
|
||||
expect(await balance(ziraat)).toBe(before - 1_234_56);
|
||||
// İkinci kez onaylanamaz.
|
||||
expect(
|
||||
(await confirmOccurrence(ayse, cakir, String(first!._id), { amount: 1, date: today })).ok,
|
||||
).toBe(false);
|
||||
|
||||
expect((await skipOccurrence(emre, cakir, String(second!._id))).ok).toBe(true);
|
||||
expect((await TransactionModel.findById(second!._id).lean())!.status).toBe("void");
|
||||
expect(await balance(ziraat)).toBe(before - 1_234_56);
|
||||
});
|
||||
|
||||
it("ertele: tarih ileri alınır, kayıt seriden ayrılır; geçmişe ertelenemez", async () => {
|
||||
const occ = await occurrences(ruleId);
|
||||
const third = occ[2]!;
|
||||
const current = third.date.toISOString();
|
||||
expect((await postponeOccurrence(ayse, cakir, String(third._id), "2000-01-01")).ok).toBe(false);
|
||||
const target = addDays(today, 200);
|
||||
expect((await postponeOccurrence(ayse, cakir, String(third._id), target)).ok).toBe(true);
|
||||
const after = (await TransactionModel.findById(third._id).lean())!;
|
||||
expect(after.detached).toBe(true);
|
||||
expect(after.date.toISOString()).not.toBe(current);
|
||||
});
|
||||
|
||||
it("tümünü güncelle: planlılar yeni tutarla yeniden üretilir; gerçekleşen, atlanan ve ayrılan korunur", async () => {
|
||||
const r = await updateRule(ayse, cakir, ruleId, await form({ amount: "1.500,00" }), "all");
|
||||
expect(r.ok).toBe(true);
|
||||
const occ = await occurrences(ruleId);
|
||||
expect(occ[0]).toMatchObject({ status: "posted", amountMinor: 1_234_56 });
|
||||
expect(occ[1]).toMatchObject({ status: "void" });
|
||||
expect(occ[2]).toMatchObject({ detached: true, amountMinor: 1_000_00 });
|
||||
expect(occ.slice(3).every((o) => o.status === "planned" && o.amountMinor === 1_500_00)).toBe(
|
||||
true,
|
||||
);
|
||||
// Sıra numaraları benzersiz.
|
||||
expect(new Set(occ.map((o) => o.occurrenceIndex)).size).toBe(occ.length);
|
||||
});
|
||||
|
||||
it("bu ve sonrakiler: seri bölünür", async () => {
|
||||
const occ = await occurrences(ruleId);
|
||||
const k = 5;
|
||||
const from = occ.find((o) => o.occurrenceIndex === k)!;
|
||||
const r = await updateRule(
|
||||
ayse,
|
||||
cakir,
|
||||
ruleId,
|
||||
await form({ amount: "2.000,00", startDate: addDays(today, 100) }),
|
||||
"following",
|
||||
String(from._id),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
const newId = r.ok ? r.data.id : "";
|
||||
expect(newId).not.toBe(ruleId);
|
||||
expect((await RecurringRuleModel.findById(ruleId).lean())!.count).toBe(k);
|
||||
const old = await occurrences(ruleId);
|
||||
expect(old.every((o) => o.occurrenceIndex! < k)).toBe(true);
|
||||
const fresh = await occurrences(newId);
|
||||
expect(fresh.length).toBeGreaterThan(0);
|
||||
expect(fresh.every((o) => o.amountMinor === 2_000_00)).toBe(true);
|
||||
});
|
||||
|
||||
it("tek kaydı düzenlemek (yalnızca bu) kaydı seriden ayırır", async () => {
|
||||
const r = await createRule(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({ startDate: addDays(today, 3), endKind: "count", count: 3 }),
|
||||
);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
const occ = await occurrences(id);
|
||||
expect(occ).toHaveLength(3);
|
||||
const d = (await getTransaction(ayse, cakir, String(occ[1]!._id)))!;
|
||||
expect(d.status).toBe("planned");
|
||||
const u = await updateTransaction(ayse, cakir, d.id, {
|
||||
type: "expense",
|
||||
amount: 777_00,
|
||||
date: d.date,
|
||||
accountId: d.account.id,
|
||||
toAccountId: null,
|
||||
categoryId: d.categoryId,
|
||||
splits: [],
|
||||
payee: d.payeeName,
|
||||
description: "",
|
||||
tags: [],
|
||||
attachmentIds: [],
|
||||
});
|
||||
expect(u.ok).toBe(true);
|
||||
expect(await TransactionModel.findById(d.id).lean()).toMatchObject({
|
||||
detached: true,
|
||||
status: "planned",
|
||||
amountMinor: 777_00,
|
||||
});
|
||||
// Silme: bu ve sonrakiler (index 1'den) → yalnızca ilk kalır, kural 1 tekrarla biter.
|
||||
expect((await deleteRule(ayse, cakir, id, "following", d.id)).ok).toBe(true);
|
||||
expect(await occurrences(id)).toHaveLength(1);
|
||||
expect((await deleteRule(ayse, cakir, id, "all")).ok).toBe(true);
|
||||
expect(await occurrences(id)).toHaveLength(0);
|
||||
expect((await RecurringRuleModel.findById(id).lean())!.deletedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("yetki ve izolasyon", () => {
|
||||
it("izleyici kural oluşturamaz/onaylayamaz; hane dışı göremez", async () => {
|
||||
expect((await createRule(zeynep, cakir, await form())).ok).toBe(false);
|
||||
const o = (await getPlannedOverview(zeynep, cakir))!;
|
||||
const any = o.upcoming[0]!;
|
||||
expect((await confirmOccurrence(zeynep, cakir, any.id, { amount: 1, date: today })).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await getPlannedOverview(fatma, cakir)).toBeNull();
|
||||
expect((await skipOccurrence(fatma, cakir, any.id)).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("gizli hesap kuralı ve kayıtları yalnızca sahibine", async () => {
|
||||
const pluxee = await acc("Ayşe — Pluxee");
|
||||
expect(
|
||||
(
|
||||
await createRule(
|
||||
emre,
|
||||
cakir,
|
||||
await form({ accountId: pluxee, startDate: addDays(today, 1) }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
const r = await createRule(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({ accountId: pluxee, startDate: addDays(today, 1), payee: "Gizli kural" }),
|
||||
);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
const mine = (await getPlannedOverview(ayse, cakir))!;
|
||||
expect(mine.rules.some((x) => x.id === id)).toBe(true);
|
||||
const theirs = (await getPlannedOverview(emre, cakir))!;
|
||||
expect(theirs.rules.some((x) => x.id === id)).toBe(false);
|
||||
expect(theirs.upcoming.some((x) => x.recurringRuleId === id)).toBe(false);
|
||||
expect((await deleteRule(emre, cakir, id, "all")).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("form doğrulaması: bitiş tarihi başlangıçtan önce olamaz; transferde hedef hesap", () => {
|
||||
expect(
|
||||
recurringRuleFormSchema.safeParse({
|
||||
type: "expense",
|
||||
amount: "10",
|
||||
accountId: "a".repeat(24),
|
||||
categoryId: "b".repeat(24),
|
||||
startDate: "2026-05-01",
|
||||
frequency: "monthly",
|
||||
endKind: "date",
|
||||
endDate: "2026-04-01",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
recurringRuleFormSchema.safeParse({
|
||||
type: "transfer",
|
||||
amount: "10",
|
||||
accountId: "a".repeat(24),
|
||||
startDate: "2026-05-01",
|
||||
frequency: "monthly",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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<number> {
|
||||
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<RuleDoc[]>();
|
||||
let created = 0;
|
||||
for (const rule of rules) created += await generate(rule, today);
|
||||
return created;
|
||||
}
|
||||
|
||||
function ruleVisible(ctx: Ctx, rule: Pick<RuleDoc, "accountId" | "toAccountId">) {
|
||||
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<RuleDoc>();
|
||||
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<ActionResult<{ id: string; generated: number }>> {
|
||||
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<ActionResult<{ id: string }>> {
|
||||
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<RuleDoc>();
|
||||
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<ActionResult<{ removed: number }>> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<string, unknown>, 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<PlannedOverview | null> {
|
||||
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<RuleView[]> {
|
||||
const rules = (
|
||||
await RecurringRuleModel.find({ householdId: ctx.m.householdId, deletedAt: null })
|
||||
.sort({ createdAt: 1 })
|
||||
.lean<RuleDoc[]>()
|
||||
).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<Awaited<ReturnType<typeof getRuleForm>>>;
|
||||
|
||||
/** Dashboard uyarısı: vadesi geçmiş onaylanmamış planlı kayıt sayısı. */
|
||||
export async function countOverdue(userId: string, householdId: string): Promise<number> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return 0;
|
||||
return TransactionModel.countDocuments({
|
||||
...baseFilter(ctx),
|
||||
status: "planned",
|
||||
date: { $lt: istanbulDayStart(todayIstanbul())! },
|
||||
});
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export async function loadContext(
|
||||
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
|
||||
|
||||
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
|
||||
function baseFilter(ctx: Ctx): Record<string, unknown> {
|
||||
export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
||||
const f: Record<string, unknown> = { 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<string, unknown> {
|
||||
};
|
||||
}
|
||||
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"] },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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<typeof recurringRuleSchema>;
|
||||
|
||||
export const RecurringRuleModel: Model<RecurringRule> =
|
||||
(models.RecurringRule as Model<RecurringRule> | undefined) ??
|
||||
model<RecurringRule>("RecurringRule", recurringRuleSchema);
|
||||
@@ -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" } } },
|
||||
|
||||
@@ -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>): 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");
|
||||
});
|
||||
});
|
||||
@@ -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<Recurrence, "frequency" | "interval" | "monthlyMode" | "startDate">,
|
||||
): 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]}`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof recurringRuleFormSchema>;
|
||||
export type RecurringRuleForm = z.output<typeof recurringRuleFormSchema>;
|
||||
|
||||
/** Planlı kaydı onaylama: tutar ve tarih düzeltilebilir. */
|
||||
export const confirmOccurrenceSchema = z.object({
|
||||
amount: positiveMoney,
|
||||
date: ymd,
|
||||
});
|
||||
export type ConfirmOccurrenceForm = z.output<typeof confirmOccurrenceSchema>;
|
||||
|
||||
export const SERIES_SCOPES = ["one", "following", "all"] as const;
|
||||
export type SeriesScope = (typeof SERIES_SCOPES)[number];
|
||||
Reference in New Issue
Block a user