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:
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user