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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 10:55:05 +03:00
parent eaafe5dd9b
commit 04a17d2775
13 changed files with 1493 additions and 7 deletions
+95
View File
@@ -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");
});
});
+136
View File
@@ -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]}`;
}
}
+50
View File
@@ -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,
);
});
});
+94
View File
@@ -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];