feat(cards): kart/KMH dönem hesabı, taksitli harcama, kart ve KMH özeti servisleri, 006 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cardPeriods,
|
||||
closingOnOrAfter,
|
||||
dueDate,
|
||||
estimateOverdraftInterest,
|
||||
installmentSchedule,
|
||||
minimumPayment,
|
||||
minimumRateBp,
|
||||
nextPeriod,
|
||||
periodContaining,
|
||||
} from "./card-periods";
|
||||
|
||||
describe("dönem hesabı", () => {
|
||||
it("kesim günü dahil hesap kesim dönemine girer; ertesi gün yeni dönem", () => {
|
||||
expect(periodContaining("2026-10-29", 29)).toEqual({ start: "2026-09-30", end: "2026-10-29" });
|
||||
expect(periodContaining("2026-10-30", 29)).toEqual({ start: "2026-10-30", end: "2026-11-29" });
|
||||
});
|
||||
|
||||
it("kesim günü ayda yoksa ayın son günü (31 → Şubat 28/29)", () => {
|
||||
expect(closingOnOrAfter("2026-02-10", 31)).toBe("2026-02-28");
|
||||
expect(periodContaining("2028-03-05", 31)).toEqual({ start: "2028-03-01", end: "2028-03-31" });
|
||||
expect(periodContaining("2026-03-01", 30)).toEqual({ start: "2026-03-01", end: "2026-03-30" });
|
||||
expect(periodContaining("2026-02-28", 30)).toEqual({ start: "2026-01-31", end: "2026-02-28" });
|
||||
});
|
||||
|
||||
it("yıl dönümü ve sonraki dönem", () => {
|
||||
const p = periodContaining("2026-12-31", 15);
|
||||
expect(p).toEqual({ start: "2026-12-16", end: "2027-01-15" });
|
||||
expect(nextPeriod(p, 15)).toEqual({ start: "2027-01-16", end: "2027-02-15" });
|
||||
});
|
||||
|
||||
it("güncel dönem ve son ekstre; kesim günü bugün iken dönem henüz kapanmamış", () => {
|
||||
expect(cardPeriods("2026-11-05", 29)).toEqual({
|
||||
current: { start: "2026-10-30", end: "2026-11-29" },
|
||||
statement: { start: "2026-09-30", end: "2026-10-29" },
|
||||
});
|
||||
expect(cardPeriods("2026-11-29", 29).current.end).toBe("2026-11-29");
|
||||
});
|
||||
|
||||
it("son ödeme tarihi", () => {
|
||||
expect(dueDate("2026-10-29", { kind: "daysAfter", value: 10 })).toBe("2026-11-08");
|
||||
expect(dueDate("2026-10-29", { kind: "fixedDay", value: 9 })).toBe("2026-11-09");
|
||||
expect(dueDate("2026-10-05", { kind: "fixedDay", value: 20 })).toBe("2026-10-20");
|
||||
expect(dueDate("2026-01-29", { kind: "fixedDay", value: 31 })).toBe("2026-01-31");
|
||||
});
|
||||
});
|
||||
|
||||
describe("asgari ödeme", () => {
|
||||
it("limite göre %20 / %40; kart bazında oran öncelikli; yukarı yuvarlama", () => {
|
||||
expect(minimumRateBp(49_999_99, null)).toBe(2000);
|
||||
expect(minimumRateBp(50_000_00, null)).toBe(4000);
|
||||
expect(minimumRateBp(60_000_00, 2500)).toBe(2500);
|
||||
expect(minimumPayment(10_000_01, 2000)).toBe(2_000_01);
|
||||
expect(minimumPayment(-5, 2000)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taksit planı", () => {
|
||||
it("eşit bölme (kuruş farkı ilk taksitlerde), aylık tarihler, ay sonu", () => {
|
||||
const s = installmentSchedule("2026-01-31", 10_000_00, 3);
|
||||
expect(s.map((x) => x.amountMinor)).toEqual([3_333_34, 3_333_33, 3_333_33]);
|
||||
expect(s.reduce((a, x) => a + x.amountMinor, 0)).toBe(10_000_00);
|
||||
expect(s.map((x) => x.date)).toEqual(["2026-01-31", "2026-02-28", "2026-03-31"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KMH faiz tahmini", () => {
|
||||
it("gün sonu eksi bakiyeler üzerinden", () => {
|
||||
const period = { start: "2026-11-01", end: "2026-11-30" };
|
||||
// 10 gün boyunca -3.000 TL; sonra sıfır.
|
||||
const deltas = new Map([
|
||||
["2026-11-01", -3_000_00],
|
||||
["2026-11-11", 3_000_00],
|
||||
]);
|
||||
const r = estimateOverdraftInterest(period, 0, deltas, 425);
|
||||
// 10 gün × 3.000 TL × %4,25 / 30 = 42,50 TL
|
||||
expect(r.interestMinor).toBe(42_50);
|
||||
expect(r.days).toBe(30);
|
||||
expect(estimateOverdraftInterest(period, 5_000_00, deltas, 425).interestMinor).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { splitEvenly } from "./money";
|
||||
import { addDays, addMonths, daysInMonth } from "./recurrence";
|
||||
|
||||
/**
|
||||
* Kredi kartı ve KMH dönemleri (Bölüm 6.8). Saf fonksiyonlar; tarihler "YYYY-MM-DD" (İstanbul).
|
||||
*
|
||||
* - Kesim günü ayda yoksa (ör. 31) ayın son günü kullanılır.
|
||||
* - Kesim günü DAHİL o gün ve öncesi "Hesap Kesim Dönemi"ne girer (Bölüm 10 varsayılanı).
|
||||
* - Kesim günü bitmeden dönem kapanmış sayılmaz: bugün kesim günüyse işlemler hâlâ "Güncel Dönem"de.
|
||||
*/
|
||||
|
||||
export type DueRule = { kind: "daysAfter" | "fixedDay"; value: number };
|
||||
export type Period = { start: string; end: string };
|
||||
|
||||
/** Asgari ödeme varsayılanı (Faz 7 kararı): limit < 50.000 TL → %20, üstü → %40. */
|
||||
export const MINIMUM_RATE_THRESHOLD_MINOR = 50_000_00;
|
||||
export const MAX_INSTALLMENTS = 36;
|
||||
|
||||
/** O ayın kesim tarihi. */
|
||||
export function closingDateIn(ym: string, statementDay: number): string {
|
||||
const [y, m] = ym.split("-").map(Number) as [number, number];
|
||||
const day = Math.min(statementDay, daysInMonth(y, m));
|
||||
return `${ym}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Tarihi içeren dönemin kesim tarihi (tarih kesim gününe eşitse o gün). */
|
||||
export function closingOnOrAfter(ymd: string, statementDay: number): string {
|
||||
const c = closingDateIn(ymd.slice(0, 7), statementDay);
|
||||
return ymd <= c
|
||||
? c
|
||||
: closingDateIn(addMonths(`${ymd.slice(0, 7)}-01`, 1).slice(0, 7), statementDay);
|
||||
}
|
||||
|
||||
/** Tarihi içeren dönem: önceki kesimin ertesi günü → bu kesim (dahil). */
|
||||
export function periodContaining(ymd: string, statementDay: number): Period {
|
||||
const end = closingOnOrAfter(ymd, statementDay);
|
||||
const prevMonth = addMonths(`${end.slice(0, 7)}-01`, -1).slice(0, 7);
|
||||
return { start: addDays(closingDateIn(prevMonth, statementDay), 1), end };
|
||||
}
|
||||
|
||||
export function previousPeriod(p: Period, statementDay: number): Period {
|
||||
return periodContaining(addDays(p.start, -1), statementDay);
|
||||
}
|
||||
|
||||
export function nextPeriod(p: Period, statementDay: number): Period {
|
||||
return periodContaining(addDays(p.end, 1), statementDay);
|
||||
}
|
||||
|
||||
/** Son ödeme tarihi: kesimden N gün sonra veya kesimden sonraki ilk "ayın N. günü". */
|
||||
export function dueDate(closing: string, rule: DueRule): string {
|
||||
if (rule.kind === "daysAfter") return addDays(closing, rule.value);
|
||||
const same = addMonths(closing, 0, rule.value);
|
||||
return same > closing ? same : addMonths(closing, 1, rule.value);
|
||||
}
|
||||
|
||||
/** Bugüne göre güncel dönem (bugünü içerir) ve son kesilmiş dönem (ekstre). */
|
||||
export function cardPeriods(today: string, statementDay: number) {
|
||||
const current = periodContaining(today, statementDay);
|
||||
return { current, statement: previousPeriod(current, statementDay) };
|
||||
}
|
||||
|
||||
export function minimumRateBp(limitMinor: number, overrideBp: number | null | undefined): number {
|
||||
if (overrideBp !== null && overrideBp !== undefined) return overrideBp;
|
||||
return limitMinor < MINIMUM_RATE_THRESHOLD_MINOR ? 2000 : 4000;
|
||||
}
|
||||
|
||||
/** Asgari ödeme (kuruş, yukarı yuvarlanır). */
|
||||
export function minimumPayment(debtMinor: number, rateBp: number): number {
|
||||
if (debtMinor <= 0) return 0;
|
||||
return Math.ceil((debtMinor * rateBp) / 10_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Taksit planı: toplam eşit bölünür (kuruş farkı ilk taksitlere), her taksit alışveriş gününden
|
||||
* itibaren aylık (ayda o gün yoksa son gün) — böylece her taksit ardışık bir döneme düşer.
|
||||
*/
|
||||
export function installmentSchedule(
|
||||
purchaseYmd: string,
|
||||
totalMinor: number,
|
||||
count: number,
|
||||
): { index: number; date: string; amountMinor: number }[] {
|
||||
const day = Number(purchaseYmd.slice(8, 10));
|
||||
return splitEvenly(totalMinor, count).map((amountMinor, index) => ({
|
||||
index,
|
||||
date: addMonths(purchaseYmd, index, day),
|
||||
amountMinor,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* KMH faizi tahmini: dönemdeki her günün gün sonu eksi bakiyesi × (aylık oran / 30).
|
||||
* `startBalance`: dönem başından önceki bakiye; `deltas`: gün → o günün net hareketi.
|
||||
* Vergiler (KKDF/BSMV) dahil değildir; kullanıcı kesim gününde kesinleştirir.
|
||||
*/
|
||||
export function estimateOverdraftInterest(
|
||||
period: Period,
|
||||
startBalanceMinor: number,
|
||||
deltas: ReadonlyMap<string, number>,
|
||||
monthlyRateBp: number,
|
||||
until?: string,
|
||||
): { interestMinor: number; averageUsedMinor: number; days: number } {
|
||||
let balance = startBalanceMinor;
|
||||
let used = 0;
|
||||
let days = 0;
|
||||
const last = until && until < period.end ? until : period.end;
|
||||
for (let d = period.start; d <= last; d = addDays(d, 1)) {
|
||||
balance += deltas.get(d) ?? 0;
|
||||
used += Math.max(0, -balance);
|
||||
days++;
|
||||
}
|
||||
const interestMinor = Math.round((used * monthlyRateBp) / 10_000 / 30);
|
||||
return { interestMinor, averageUsedMinor: days ? Math.round(used / days) : 0, days };
|
||||
}
|
||||
@@ -68,6 +68,8 @@ export const accountFormSchema = z
|
||||
cardDueKind: z.enum(DUE_RULE_KINDS),
|
||||
cardDueValue: day(31),
|
||||
cardPaymentAccountId: objectIdOrEmpty,
|
||||
/** Boş → varsayılan kural (limite göre %20 / %40). */
|
||||
cardMinimumRate: z.string().default("").pipe(percentToBp),
|
||||
})
|
||||
.superRefine((a, ctx) => {
|
||||
if (a.type === "meal_card" && !a.mealCardProvider) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { parseMoney } from "../money";
|
||||
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
||||
import { MAX_INSTALLMENTS } from "../card-periods";
|
||||
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
|
||||
|
||||
const v = tr.validation;
|
||||
@@ -53,6 +54,14 @@ export const transactionFormSchema = z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.default("")
|
||||
.transform((x) => normalizeTags(x)),
|
||||
/**
|
||||
* Taksit sayısı (yalnızca kredi kartı gideri, oluştururken). 1 = peşin. Her taksit ayrı işlem
|
||||
* olur; vadesi gelmeyenler planlıdır ve kendi döneminde otomatik gerçekleşir.
|
||||
*/
|
||||
installments: z.preprocess(
|
||||
(v) => (v === "" || v === null || v === undefined ? 1 : v),
|
||||
z.coerce.number().int().min(1).max(MAX_INSTALLMENTS, t.installmentsMax),
|
||||
),
|
||||
/** 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))
|
||||
@@ -67,6 +76,9 @@ export const transactionFormSchema = z
|
||||
ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.sameAccount });
|
||||
return;
|
||||
}
|
||||
if (x.installments > 1 && (x.type !== "expense" || x.splits.length > 0)) {
|
||||
ctx.addIssue({ code: "custom", path: ["installments"], message: t.installmentsExpenseOnly });
|
||||
}
|
||||
if (x.splits.length === 1) {
|
||||
ctx.addIssue({ code: "custom", path: ["splits"], message: t.splitMin });
|
||||
} else if (x.splits.length >= 2) {
|
||||
|
||||
Reference in New Issue
Block a user