feat(accounts): hesaplar (KMH, kredi kartı, yemek kartı, yalnızca ben)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:57:58 +03:00
parent 380ddaf303
commit 8dd5fd42e3
9 changed files with 1805 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
/** Hesap türleri ve yemek kartı sağlayıcıları (Bölüm 6.1). */
export const ACCOUNT_TYPES = ["cash", "bank", "savings", "credit_card", "meal_card"] as const;
export type AccountType = (typeof ACCOUNT_TYPES)[number];
export const MEAL_CARD_PROVIDERS = [
"pluxee",
"multinet",
"edenred",
"setcard",
"metropol",
"other",
] as const;
export type MealCardProvider = (typeof MEAL_CARD_PROVIDERS)[number];
/** Kredi kartı son ödeme kuralı: kesimden N gün sonra | ayın sabit günü. */
export const DUE_RULE_KINDS = ["daysAfter", "fixedDay"] as const;
export type DueRuleKind = (typeof DUE_RULE_KINDS)[number];
/** Borç (negatif bakiye) taşıyan hesap türleri. */
export const isLiabilityType = (type: AccountType) => type === "credit_card";
+89
View File
@@ -0,0 +1,89 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { ACCOUNT_TYPES, DUE_RULE_KINDS, MEAL_CARD_PROVIDERS } from "../accounts";
import { ICON_KEYS, SWATCHES } from "../appearance";
import { parseMoney } from "../money";
const v = tr.validation;
/** Türkçe para girişi ("1.250,50") → kuruş. Boş → 0 (opsiyonel alanlarda). */
export const moneyInputSchema = z
.string()
.trim()
.transform((s, ctx) => {
if (s === "") return 0;
const minor = parseMoney(s);
if (minor === null) {
ctx.addIssue({ code: "custom", message: v.money });
return z.NEVER;
}
return minor;
});
/** Pozitif (≥ 0) para girişi. */
export const nonNegativeMoneySchema = moneyInputSchema.refine((n) => n >= 0, v.moneyNonNegative);
const day = (max: number) => z.coerce.number().int().min(1).max(max);
const objectIdOrEmpty = z
.string()
.regex(/^([a-f0-9]{24})?$/i)
.transform((s) => (s === "" ? null : s));
/** Yüzde girişi ("4,25") → baz puan (425). Boş → null. */
const percentToBp = z
.string()
.trim()
.transform((s, ctx) => {
if (s === "") return null;
const minor = parseMoney(s); // aynı ondalık kuralları: 2 hane
if (minor === null || minor < 0 || minor > 100_00) {
ctx.addIssue({ code: "custom", message: v.percent });
return z.NEVER;
}
return minor;
});
export const accountFormSchema = z
.object({
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
type: z.enum(ACCOUNT_TYPES),
institution: z.string().trim().max(80, v.nameMax),
mealCardProvider: z.enum(MEAL_CARD_PROVIDERS).nullable(),
/** Kart için "açılış borcu" pozitif girilir; sunucu negatife çevirir. */
openingBalance: moneyInputSchema,
/** yyyy-MM-dd (İstanbul günü) */
openingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date),
color: z.enum(SWATCHES),
icon: z.enum(ICON_KEYS),
ownerUserId: objectIdOrEmpty,
isPrivate: z.boolean(),
overdraftEnabled: z.boolean(),
overdraftLimit: nonNegativeMoneySchema,
overdraftMonthlyRate: percentToBp,
overdraftStatementDay: z
.union([z.literal(""), day(31)])
.transform((d) => (d === "" ? null : d)),
cardLimit: nonNegativeMoneySchema,
cardStatementDay: day(31),
cardDueKind: z.enum(DUE_RULE_KINDS),
cardDueValue: day(31),
cardPaymentAccountId: objectIdOrEmpty,
})
.superRefine((a, ctx) => {
if (a.type === "meal_card" && !a.mealCardProvider) {
ctx.addIssue({ code: "custom", path: ["mealCardProvider"], message: v.selectProvider });
}
if (a.isPrivate && !a.ownerUserId) {
ctx.addIssue({ code: "custom", path: ["ownerUserId"], message: v.privateNeedsOwner });
}
if (a.type === "credit_card" && a.cardDueKind === "daysAfter" && a.cardDueValue > 30) {
ctx.addIssue({ code: "custom", path: ["cardDueValue"], message: v.dueDays });
}
// Yalnızca banka hesabı (KMH ile) negatif açılabilir; kart borcu pozitif girilir.
if (a.openingBalance < 0 && a.type !== "bank") {
ctx.addIssue({ code: "custom", path: ["openingBalance"], message: v.moneyNonNegative });
}
});
export type AccountFormInput = z.input<typeof accountFormSchema>;
export type AccountForm = z.output<typeof accountFormSchema>;