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
+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);
});
});