feat(shared): işlem şeması, bakiye etkisi, etiket ve CSV yardımcıları

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 03:23:30 +03:00
parent a00c953dc0
commit d18c8a0d76
4 changed files with 334 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
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<typeof transactionFormSchema>;
export type TransactionForm = z.output<typeof transactionFormSchema>;
/** 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<typeof transactionFilterSchema>;