import { z } from "zod"; import { tr } from "../../i18n/tr"; import { parseMoney } from "../money"; import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions"; const v = tr.validation; const t = tr.transactions.validation; const objectId = z.string().regex(/^[a-f0-9]{24}$/i, t.selectAccount); const optionalId = z .string() .regex(/^([a-f0-9]{24})?$/i) .nullable() .optional() .transform((s) => (s ? s : null)); /** Pozitif tutar ("1.250,50" → 125050). */ 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; }); const splitSchema = z.object({ categoryId: objectId.refine(Boolean, t.selectCategory), amount: positiveMoney, note: z.string().trim().max(120).default(""), }); /** * İşlem formu (web + mobil). Kurallar: * - transfer: hedef hesap zorunlu ve kaynaktan farklı; kategori/bölme yok. * - gelir/gider: ya tek kategori ya da en az 2 kalemli bölme; kalemler toplamı = tutar. */ export const transactionFormSchema = z .object({ type: z.enum(TRANSACTION_TYPES), amount: positiveMoney, date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date), accountId: objectId, toAccountId: optionalId, categoryId: optionalId, splits: z.array(splitSchema).max(MAX_SPLITS).default([]), 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)), }) .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 }); return; } if (x.splits.length === 1) { ctx.addIssue({ code: "custom", path: ["splits"], message: t.splitMin }); } else if (x.splits.length >= 2) { const sum = x.splits.reduce((a, s) => a + s.amount, 0); if (sum !== x.amount) ctx.addIssue({ code: "custom", path: ["splits"], message: t.splitSum }); } else if (!x.categoryId) { ctx.addIssue({ code: "custom", path: ["categoryId"], message: t.selectCategory }); } }); export type TransactionFormInput = z.input; export type TransactionForm = z.output; /** Liste filtreleri (URL arama parametrelerinden). Geçersiz değerler yok sayılır. */ export const transactionFilterSchema = z.object({ from: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() .catch(undefined), to: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() .catch(undefined), type: z.enum(TRANSACTION_TYPES).optional().catch(undefined), hesap: z .string() .regex(/^[a-f0-9]{24}$/i) .optional() .catch(undefined), kategori: z .string() .regex(/^[a-f0-9]{24}$/i) .optional() .catch(undefined), kisi: z .string() .regex(/^[a-f0-9]{24}$/i) .optional() .catch(undefined), durum: z.enum(["posted", "planned", "void"]).optional().catch(undefined), min: z .string() .optional() .catch(undefined) .transform((s) => (s ? (parseMoney(s) ?? undefined) : undefined)), max: z .string() .optional() .catch(undefined) .transform((s) => (s ? (parseMoney(s) ?? undefined) : undefined)), etiket: z.string().trim().max(30).optional().catch(undefined), q: z.string().trim().max(80).optional().catch(undefined), sayfa: z.coerce.number().int().min(1).max(10_000).catch(1).default(1), }); export type TransactionFilters = z.output;