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
+29
View File
@@ -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")}`;
}
+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>;
+132
View File
@@ -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);
});
});
+53
View File
@@ -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;
}