feat(transactions): ek bağlama, hesap ekstresi ve mutabakat servisi, 004 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 07:55:04 +03:00
parent 2e1f4d7929
commit 2e2e33233f
15 changed files with 1049 additions and 22 deletions
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseMoney } from "../money";
const v = tr.validation;
/**
* Mutabakat formu (web + mobil). `statementBalance` ekstredeki bakiye; kredi kartında
* ekstredeki BORÇ tutarı pozitif girilir (sunucu uygulamadaki eksi bakiyeye çevirir).
*/
export const reconcileSchema = z.object({
statementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date),
statementBalance: z
.string()
.trim()
.transform((s, ctx) => {
const minor = parseMoney(s);
if (minor === null) {
ctx.addIssue({ code: "custom", message: v.money });
return z.NEVER;
}
return minor;
}),
/** Fark varsa düzeltme işlemi oluşturulmasını onaylar. */
acceptAdjustment: z.boolean().default(false),
});
export type ReconcileInput = z.input<typeof reconcileSchema>;
export type ReconcileForm = z.output<typeof reconcileSchema>;
+6
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseMoney } from "../money";
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
const v = tr.validation;
@@ -52,6 +53,11 @@ export const transactionFormSchema = z
.union([z.string(), z.array(z.string())])
.default("")
.transform((x) => normalizeTags(x)),
/** Bağlı ek dosyalar (sıralı). Yeni ekler önce /api/files ile yüklenir. */
attachmentIds: z
.array(z.string().regex(/^[a-f0-9]{24}$/i))
.max(MAX_ATTACHMENTS_PER_RECORD, tr.attachments.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD))
.default([]),
})
.superRefine((x, ctx) => {
if (x.type === "transfer") {
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
accountEffect,
monthRange,
MONTH_PATTERN,
shiftMonth,
withRunningBalance,
} from "./statement";
describe("ay yardımcıları", () => {
it("monthRange ve shiftMonth yıl sınırını aşar", () => {
expect(monthRange("2026-12")).toEqual({ start: "2026-12-01", end: "2027-01-01" });
expect(shiftMonth("2026-01", -1)).toBe("2025-12");
expect(shiftMonth("2026-11", 14)).toBe("2028-01");
expect(MONTH_PATTERN.test("2026-13")).toBe(false);
expect(MONTH_PATTERN.test("2026-09")).toBe(true);
});
});
describe("accountEffect", () => {
const a = "A";
it("gelir +, gider −, transfer kaynak − / hedef +, ilgisiz 0", () => {
expect(accountEffect({ type: "income", amountMinor: 500, accountId: a }, a)).toBe(500);
expect(accountEffect({ type: "expense", amountMinor: 500, accountId: a }, a)).toBe(-500);
const tr = { type: "transfer" as const, amountMinor: 300, accountId: a, toAccountId: "B" };
expect(accountEffect(tr, a)).toBe(-300);
expect(accountEffect(tr, "B")).toBe(300);
expect(accountEffect(tr, "C")).toBe(0);
expect(accountEffect({ type: "expense", amountMinor: 1, accountId: "B" }, a)).toBe(0);
});
});
describe("withRunningBalance", () => {
it("açılıştan başlayarak biriktirir", () => {
expect(
withRunningBalance(1000, [{ deltaMinor: -200 }, { deltaMinor: 50 }]).map(
(r) => r.balanceMinor,
),
).toEqual([800, 850]);
expect(withRunningBalance(5, [])).toEqual([]);
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { TransactionType } from "./transactions";
/** Hesap ekstresi yardımcıları (Bölüm 6.1). Saf fonksiyonlar; sunucu ve testler kullanır. */
export const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
/** "2026-11" → { start: "2026-11-01", end: "2026-12-01" } (end hariç). */
export function monthRange(ym: string): { start: string; end: string } {
return { start: `${ym}-01`, end: `${shiftMonth(ym, 1)}-01` };
}
/** "2026-01", -1 → "2025-12". */
export function shiftMonth(ym: string, delta: number): string {
const [y, m] = ym.split("-").map(Number) as [number, number];
const index = y * 12 + (m - 1) + delta;
return `${Math.floor(index / 12)}-${String((index % 12) + 1).padStart(2, "0")}`;
}
/** Bir işlemin belirli bir hesaba etkisi (kuruş): gelir +, gider −, transfer kaynak − / hedef +. */
export function accountEffect(
tx: {
type: TransactionType;
amountMinor: number;
accountId: string;
toAccountId?: string | null;
},
accountId: string,
): number {
if (tx.type === "transfer") {
if (tx.accountId === accountId) return -tx.amountMinor;
if (tx.toAccountId === accountId) return tx.amountMinor;
return 0;
}
if (tx.accountId !== accountId) return 0;
return tx.type === "income" ? tx.amountMinor : -tx.amountMinor;
}
/** Kronolojik sıralı etkilere yürüyen bakiye ekler. */
export function withRunningBalance<T extends { deltaMinor: number }>(
openingMinor: number,
rows: readonly T[],
): (T & { balanceMinor: number })[] {
let balance = openingMinor;
return rows.map((r) => {
balance += r.deltaMinor;
return { ...r, balanceMinor: balance };
});
}