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
+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];