diff --git a/src/shared/csv.ts b/src/shared/csv.ts new file mode 100644 index 0000000..2008b1c --- /dev/null +++ b/src/shared/csv.ts @@ -0,0 +1,29 @@ +/** + * CSV üretimi — Türkçe Excel ile uyumlu: noktalı virgül ayırıcı, ondalık virgül, + * UTF-8 BOM (Türkçe karakterler doğru açılır), CRLF satır sonu. + * Formül enjeksiyonuna karşı = + - @ ile başlayan metinler tek tırnakla etkisizleştirilir. + */ +export const CSV_SEPARATOR = ";"; +const BOM = ""; + +export type CsvValue = string | number | null | undefined; + +function escapeCell(value: CsvValue): string { + if (value === null || value === undefined) return ""; + let s = typeof value === "number" ? String(value) : value; + // Sayısal görünen değerler (ör. "-1250,50") olduğu gibi kalır; diğerleri formül olarak çalışmasın. + if (typeof value === "string" && /^[=+\-@\t\r]/.test(s) && !/^-?\d+(,\d+)?$/.test(s)) s = `'${s}`; + return /[";\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function toCsv(header: readonly string[], rows: readonly (readonly CsvValue[])[]): string { + const lines = [header, ...rows].map((r) => r.map(escapeCell).join(CSV_SEPARATOR)); + return BOM + lines.join("\r\n") + "\r\n"; +} + +/** Kuruş → "1250,50" (CSV'de sayı olarak okunur; binlik ayırıcı yok). */ +export function csvMoney(amountMinor: number): string { + const sign = amountMinor < 0 ? "-" : ""; + const abs = Math.abs(amountMinor); + return `${sign}${Math.trunc(abs / 100)},${String(abs % 100).padStart(2, "0")}`; +} diff --git a/src/shared/schemas/transaction.ts b/src/shared/schemas/transaction.ts new file mode 100644 index 0000000..e160eca --- /dev/null +++ b/src/shared/schemas/transaction.ts @@ -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; +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; diff --git a/src/shared/transactions.test.ts b/src/shared/transactions.test.ts new file mode 100644 index 0000000..71e0317 --- /dev/null +++ b/src/shared/transactions.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { toCsv, csvMoney } from "./csv"; +import { transactionFormSchema } from "./schemas/transaction"; +import { normalizeTags, transactionEffects } from "./transactions"; + +const A = "a".repeat(24); +const B = "b".repeat(24); +const C1 = "c".repeat(24); +const C2 = "d".repeat(24); + +describe("transactionEffects", () => { + it("gelir +, gider −, transfer kaynaktan − hedefe +", () => { + expect( + transactionEffects({ type: "income", status: "posted", amountMinor: 500, accountId: A }), + ).toEqual([{ accountId: A, deltaMinor: 500 }]); + expect( + transactionEffects({ type: "expense", status: "posted", amountMinor: 500, accountId: A }), + ).toEqual([{ accountId: A, deltaMinor: -500 }]); + expect( + transactionEffects({ + type: "transfer", + status: "posted", + amountMinor: 500, + accountId: A, + toAccountId: B, + }), + ).toEqual([ + { accountId: A, deltaMinor: -500 }, + { accountId: B, deltaMinor: 500 }, + ]); + }); + + it("planlı, iptal ve silinmiş işlemler etkisizdir", () => { + for (const x of [ + { status: "planned" as const }, + { status: "void" as const }, + { status: "posted" as const, deleted: true }, + ]) { + expect(transactionEffects({ type: "expense", amountMinor: 1, accountId: A, ...x })).toEqual( + [], + ); + } + }); +}); + +describe("normalizeTags", () => { + it("küçük harf, kırpma, tekrar yok, en fazla 10", () => { + expect(normalizeTags(" Market, İNDİRİM ,market,,#tatil")).toEqual([ + "market", + "indirim", + "tatil", + ]); + expect(normalizeTags(Array.from({ length: 15 }, (_, i) => `e${i}`))).toHaveLength(10); + }); +}); + +describe("transactionFormSchema", () => { + const base = { type: "expense", amount: "1.200,00", date: "2026-11-05", accountId: A }; + + it("tek kategorili gideri kabul eder ve tutarı kuruşa çevirir", () => { + const r = transactionFormSchema.parse({ ...base, categoryId: C1, tags: "ev, Ev" }); + expect(r.amount).toBe(120000); + expect(r.categoryId).toBe(C1); + expect(r.tags).toEqual(["ev"]); + expect(r.splits).toEqual([]); + }); + + it("kategorisiz ve bölmesiz gideri reddeder", () => { + expect(transactionFormSchema.safeParse(base).success).toBe(false); + }); + + it("bölünmüş işlemde kalem toplamı tutara eşit olmalı", () => { + const ok = transactionFormSchema.safeParse({ + ...base, + splits: [ + { categoryId: C1, amount: "800" }, + { categoryId: C2, amount: "250" }, + { categoryId: C1, amount: "150,00" }, + ], + }); + expect(ok.success).toBe(true); + const bad = transactionFormSchema.safeParse({ + ...base, + splits: [ + { categoryId: C1, amount: "800" }, + { categoryId: C2, amount: "250" }, + ], + }); + expect(bad.success).toBe(false); + expect(bad.error?.issues[0]?.path).toEqual(["splits"]); + const single = transactionFormSchema.safeParse({ + ...base, + splits: [{ categoryId: C1, amount: "1200" }], + }); + expect(single.success).toBe(false); + }); + + it("transfer: hedef zorunlu, kaynaktan farklı", () => { + expect(transactionFormSchema.safeParse({ ...base, type: "transfer" }).success).toBe(false); + expect( + transactionFormSchema.safeParse({ ...base, type: "transfer", toAccountId: A }).success, + ).toBe(false); + expect( + transactionFormSchema.safeParse({ ...base, type: "transfer", toAccountId: B }).success, + ).toBe(true); + }); + + it("sıfır, negatif ve geçersiz tutarı reddeder", () => { + for (const amount of ["0", "-5", "abc", ""]) { + expect( + transactionFormSchema.safeParse({ ...base, categoryId: C1, amount }).success, + amount, + ).toBe(false); + } + }); +}); + +describe("CSV", () => { + it("BOM, noktalı virgül, kaçış ve formül enjeksiyonu koruması", () => { + const csv = toCsv( + ["A", "B"], + [ + ["x;y", '"tırnak"'], + ["=HYPERLINK(1)", csvMoney(-125050)], + ], + ); + expect(csv.startsWith("")).toBe(true); + expect(csv).toContain('"x;y";"""tırnak"""'); + expect(csv).toContain("'=HYPERLINK(1);-1250,50"); + expect(csv.endsWith("\r\n")).toBe(true); + }); +}); diff --git a/src/shared/transactions.ts b/src/shared/transactions.ts new file mode 100644 index 0000000..a62e0ce --- /dev/null +++ b/src/shared/transactions.ts @@ -0,0 +1,53 @@ +/** İşlem türleri ve durumları (Bölüm 6.3). */ +export const TRANSACTION_TYPES = ["expense", "income", "transfer"] as const; +export type TransactionType = (typeof TRANSACTION_TYPES)[number]; + +/** planned: Faz 7 (planlı işlemler) · posted: gerçekleşti · void: iptal (bakiyeyi etkilemez). */ +export const TRANSACTION_STATUSES = ["planned", "posted", "void"] as const; +export type TransactionStatus = (typeof TRANSACTION_STATUSES)[number]; + +export const MAX_SPLITS = 20; +export const MAX_TAGS = 10; + +export type EffectInput = { + type: TransactionType; + status: TransactionStatus; + amountMinor: number; + accountId: string; + toAccountId?: string | null; + deleted?: boolean; +}; + +/** + * İşlemin hesap bakiyelerine etkisi (kuruş). Tutar her zaman pozitiftir; işaret türden gelir: + * gelir +, gider −, transfer kaynaktan −, hedefe +. Planlı, iptal ve silinmiş işlemler etkisizdir. + * Kredi kartında gider borcu artırır (bakiye daha negatif), karta transfer borcu azaltır. + */ +export function transactionEffects(t: EffectInput): { accountId: string; deltaMinor: number }[] { + if (t.status !== "posted" || t.deleted) return []; + switch (t.type) { + case "income": + return [{ accountId: t.accountId, deltaMinor: t.amountMinor }]; + case "expense": + return [{ accountId: t.accountId, deltaMinor: -t.amountMinor }]; + case "transfer": + return t.toAccountId + ? [ + { accountId: t.accountId, deltaMinor: -t.amountMinor }, + { accountId: t.toAccountId, deltaMinor: t.amountMinor }, + ] + : []; + } +} + +/** "market, İndirim ,market" → ["market", "indirim"] (küçük harf, tekrar yok, en fazla 10). */ +export function normalizeTags(input: string | readonly string[]): string[] { + const parts = typeof input === "string" ? input.split(/[,#]/) : input; + const out: string[] = []; + for (const raw of parts) { + const tag = raw.trim().replace(/\s+/g, " ").toLocaleLowerCase("tr-TR").slice(0, 30); + if (tag && !out.includes(tag)) out.push(tag); + if (out.length >= MAX_TAGS) break; + } + return out; +}