From cc624af69fcd24e86af7b53113e9868f863a0535 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 11:19:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(cards):=20kart/KMH=20d=C3=B6nem=20hesab?= =?UTF-8?q?=C4=B1,=20taksitli=20harcama,=20kart=20ve=20KMH=20=C3=B6zeti=20?= =?UTF-8?q?servisleri,=20006=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/006-card-statements.ts | 12 + scripts/migrations/index.ts | 3 +- .../components/account-form-dialog.tsx | 28 ++ src/features/accounts/service.int.test.ts | 1 + src/features/accounts/service.ts | 2 + src/features/attachments/service.int.test.ts | 1 + src/features/recurring/service.int.test.ts | 1 + src/features/recurring/service.ts | 1 + src/features/transactions/actions.ts | 14 + src/features/transactions/card.int.test.ts | 297 +++++++++++++ src/features/transactions/card.ts | 396 ++++++++++++++++++ src/features/transactions/service.int.test.ts | 2 + src/features/transactions/service.ts | 114 ++++- .../transactions/statement.int.test.ts | 3 + src/features/transactions/statement.ts | 4 +- src/models/account.ts | 5 + src/models/card-statement.ts | 38 ++ src/models/transaction.ts | 1 + src/shared/card-periods.test.ts | 82 ++++ src/shared/card-periods.ts | 113 +++++ src/shared/schemas/account.ts | 2 + src/shared/schemas/transaction.ts | 12 + 22 files changed, 1113 insertions(+), 19 deletions(-) create mode 100644 scripts/migrations/006-card-statements.ts create mode 100644 src/features/transactions/card.int.test.ts create mode 100644 src/features/transactions/card.ts create mode 100644 src/models/card-statement.ts create mode 100644 src/shared/card-periods.test.ts create mode 100644 src/shared/card-periods.ts diff --git a/scripts/migrations/006-card-statements.ts b/scripts/migrations/006-card-statements.ts new file mode 100644 index 0000000..239edfa --- /dev/null +++ b/scripts/migrations/006-card-statements.ts @@ -0,0 +1,12 @@ +import { CardStatementModel } from "@/models/card-statement"; +import { TransactionModel } from "@/models/transaction"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "006-card-statements", + description: "statements (kart/KMH dönemleri) index'leri; taksit planı index'i", + async up() { + await CardStatementModel.createIndexes(); + await TransactionModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 0e2d92b..a4e2f90 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -5,10 +5,11 @@ import { migration as m002 } from "./002-accounts-categories"; import { migration as m003 } from "./003-transactions"; import { migration as m004 } from "./004-attachments-reconciliation"; import { migration as m005 } from "./005-recurring"; +import { migration as m006 } from "./006-card-statements"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ -export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005]; +export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006]; const COLLECTION = "migrations"; diff --git a/src/features/accounts/components/account-form-dialog.tsx b/src/features/accounts/components/account-form-dialog.tsx index 879f418..599bd73 100644 --- a/src/features/accounts/components/account-form-dialog.tsx +++ b/src/features/accounts/components/account-form-dialog.tsx @@ -79,6 +79,7 @@ type FormValues = { cardDueKind: AccountFormInput["cardDueKind"]; cardDueValue: string; cardPaymentAccountId: string; + cardMinimumRate: string; }; const money = (minor: number | null | undefined) => (minor ? formatMoneyInput(minor) : ""); @@ -105,6 +106,7 @@ function toValues(a?: AccountView): FormValues { cardDueKind: "daysAfter", cardDueValue: "10", cardPaymentAccountId: "", + cardMinimumRate: "", }; } const isCard = a.type === "credit_card"; @@ -133,6 +135,10 @@ function toValues(a?: AccountView): FormValues { cardDueKind: a.creditCard?.dueRule.kind ?? "daysAfter", cardDueValue: String(a.creditCard?.dueRule.value ?? 10), cardPaymentAccountId: a.creditCard?.paymentAccountId ?? "", + cardMinimumRate: + a.creditCard?.minimumPaymentRateBp != null + ? formatMoneyInput(a.creditCard.minimumPaymentRateBp) + : "", }; } @@ -499,6 +505,28 @@ export function AccountFormDialog({ )} /> + ( + + {f.cardMinimumRate} + + + {f.cardMinimumRateHint} + + {fieldState.invalid ? : null} + + )} + /> ) : null} diff --git a/src/features/accounts/service.int.test.ts b/src/features/accounts/service.int.test.ts index 84ecd95..fc48426 100644 --- a/src/features/accounts/service.int.test.ts +++ b/src/features/accounts/service.int.test.ts @@ -35,6 +35,7 @@ const base = { cardDueKind: "daysAfter" as const, cardDueValue: 10, cardPaymentAccountId: null, + cardMinimumRate: null, }; const accountId = (name: string, householdId = cakir) => diff --git a/src/features/accounts/service.ts b/src/features/accounts/service.ts index 08c9de5..f2d8cc4 100644 --- a/src/features/accounts/service.ts +++ b/src/features/accounts/service.ts @@ -55,6 +55,7 @@ export type AccountView = { statementDay: number; dueRule: { kind: DueRuleKind; value: number }; paymentAccountId: string | null; + minimumPaymentRateBp: number | null; } | null; }; @@ -229,6 +230,7 @@ async function toDocFields( statementDay: input.cardStatementDay, dueRule: { kind: input.cardDueKind, value: input.cardDueValue }, paymentAccountId, + minimumPaymentRateBp: input.cardMinimumRate, } : null, }); diff --git a/src/features/attachments/service.int.test.ts b/src/features/attachments/service.int.test.ts index 464ffa9..94582f1 100644 --- a/src/features/attachments/service.int.test.ts +++ b/src/features/attachments/service.int.test.ts @@ -67,6 +67,7 @@ async function form(over: Partial): Promise { description: "", tags: [], attachmentIds: [], + installments: 1, ...over, }; } diff --git a/src/features/recurring/service.int.test.ts b/src/features/recurring/service.int.test.ts index fca6de0..f912a0d 100644 --- a/src/features/recurring/service.int.test.ts +++ b/src/features/recurring/service.int.test.ts @@ -204,6 +204,7 @@ describe("kural ve planlı kayıtlar", () => { description: "", tags: [], attachmentIds: [], + installments: 1, }); expect(u.ok).toBe(true); expect(await TransactionModel.findById(d.id).lean()).toMatchObject({ diff --git a/src/features/recurring/service.ts b/src/features/recurring/service.ts index 7792615..a83101b 100644 --- a/src/features/recurring/service.ts +++ b/src/features/recurring/service.ts @@ -86,6 +86,7 @@ function asTransactionForm(input: RecurringRuleForm): TransactionForm { description: input.description, tags: input.tags, attachmentIds: [], + installments: 1, }; } diff --git a/src/features/transactions/actions.ts b/src/features/transactions/actions.ts index ae95cbc..aa373af 100644 --- a/src/features/transactions/actions.ts +++ b/src/features/transactions/actions.ts @@ -8,6 +8,7 @@ import { getHouseholdActionContext } from "@/lib/dal"; import { reconcileSchema } from "@/shared/schemas/reconciliation"; import { transactionFormSchema } from "@/shared/schemas/transaction"; import { + cancelRemainingInstallments, createTransaction, getTransaction, getTransactionFormOptions, @@ -106,6 +107,19 @@ export async function searchPayeesAction(query: unknown): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(transactionId); + if (!id.success) return fail(tr.common.notFound); + const result = await cancelRemainingInstallments(ctx.user.id, ctx.household.id, id.data); + if (result.ok) revalidate(); + return result; +} + /** Mutabakat önizlemesi: ekstre bakiyesi ile uygulama bakiyesi arasındaki fark. */ export async function previewReconciliationAction( accountId: unknown, diff --git a/src/features/transactions/card.int.test.ts b/src/features/transactions/card.int.test.ts new file mode 100644 index 0000000..1f1d0cd --- /dev/null +++ b/src/features/transactions/card.int.test.ts @@ -0,0 +1,297 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createAccount, listAccounts } from "@/features/accounts/service"; +import { AccountModel } from "@/models/account"; +import { CardStatementModel } from "@/models/card-statement"; +import { CategoryModel } from "@/models/category"; +import { TransactionModel } from "@/models/transaction"; +import { cardPeriods, minimumPayment } from "@/shared/card-periods"; +import { istanbulDayStart, todayIstanbul } from "@/shared/dates"; +import { addDays } from "@/shared/recurrence"; +import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction"; +import { transactionEffects } from "@/shared/transactions"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { getCardSummary, getOverdraftSummary } from "./card"; +import { + cancelRemainingInstallments, + createTransaction, + listTransactions, + postDueInstallments, +} from "./service"; + +let ids: Awaited>; +let cakir = ""; +let ayse = ""; +let fatma = ""; +const today = todayIstanbul(); + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + ayse = ids.user("ayse"); + fatma = ids.user("fatma"); +}); +afterAll(teardownDb); + +const acc = (name: string) => + AccountModel.findOne({ householdId: cakir, name }) + .lean() + .then((a) => String(a!._id)); +const cat = (key: string) => + CategoryModel.findOne({ householdId: cakir, defaultKey: key }) + .lean() + .then((c) => String(c!._id)); + +async function form(over: Partial): Promise { + return { + type: "expense", + amount: 100_00, + date: today, + accountId: await acc("Garanti Bonus"), + toAccountId: null, + categoryId: await cat("giyim"), + splits: [], + payee: "", + description: "", + tags: [], + installments: 1, + attachmentIds: [], + ...over, + }; +} + +/** Bağımsız hesap: açılış + belirli andan önceki gerçekleşmiş işlemler. */ +async function balanceAt(accountId: string, before?: Date) { + const account = (await AccountModel.findById(accountId).lean())!; + const txs = await TransactionModel.find({ + householdId: cakir, + deletedAt: null, + ...(before ? { date: { $lt: before } } : {}), + }).lean(); + let b = account.openingBalanceMinor; + for (const t of txs) { + for (const e of transactionEffects({ + type: t.type, + status: t.status, + amountMinor: t.amountMinor, + accountId: String(t.accountId), + toAccountId: t.toAccountId ? String(t.toAccountId) : null, + })) + if (e.accountId === accountId) b += e.deltaMinor; + } + return b; +} + +describe("kart özeti", () => { + it("anlık borç, ekstre borcu (kesim günü dahil) ve asgari ödeme işlemlerle tutarlı", async () => { + const card = await acc("Garanti Bonus"); + const s = (await getCardSummary(ayse, cakir, card))!; + const { statement, current } = cardPeriods(today, 29); + expect(s.statement.period).toEqual(statement); + expect(s.current.start).toBe(current.start); + expect(s.debtMinor).toBe(Math.max(0, -(await balanceAt(card)))); + const endEx = new Date(istanbulDayStart(statement.end)!.getTime() + 24 * 3600 * 1000); + expect(s.statement.debtMinor).toBe(Math.max(0, -(await balanceAt(card, endEx)))); + // Limit 60.000 TL ≥ 50.000 → %40. + expect(s.minimumRateBp).toBe(4000); + expect(s.statement.minimumMinor).toBe(minimumPayment(s.statement.debtMinor, 4000)); + expect(s.history).toHaveLength(6); + expect(s.paymentAccount?.name).toBe("Ziraat Maaş"); + expect(await CardStatementModel.countDocuments({ accountId: card })).toBe(6); + }); + + it("karta ödeme ekstre borcunu düşürür", async () => { + const card = await acc("Garanti Bonus"); + const before = (await getCardSummary(ayse, cakir, card))!; + const r = await createTransaction( + ayse, + cakir, + await form({ + type: "transfer", + amount: 1_000_00, + accountId: await acc("Ziraat Maaş"), + toAccountId: card, + categoryId: null, + }), + ); + expect(r.ok).toBe(true); + const after = (await getCardSummary(ayse, cakir, card))!; + expect(after.statement.paidMinor).toBe(before.statement.paidMinor + 1_000_00); + expect(after.debtMinor).toBe(before.debtMinor - 1_000_00); + }); + + it("yetki: hane dışı ve kart olmayan hesap", async () => { + expect(await getCardSummary(fatma, cakir, await acc("Garanti Bonus"))).toBeNull(); + expect(await getCardSummary(ayse, cakir, await acc("Cüzdan"))).toBeNull(); + }); +}); + +describe("taksitli harcama", () => { + let first = ""; + + it("her taksit ayrı işlem; vadesi gelenler gerçekleşir, gelecekler planlı ve limitten düşer", async () => { + const card = await acc("Garanti Bonus"); + const balanceBefore = await balanceAt(card); + const summaryBefore = (await getCardSummary(ayse, cakir, card))!; + const date = addDays(today, -65); + const r = await createTransaction( + ayse, + cakir, + await form({ amount: 9_000_00, installments: 9, payee: "Hepsiburada", date }), + ); + expect(r.ok).toBe(true); + first = r.ok ? r.data.id : ""; + const plan = (await TransactionModel.findById(first).lean())!.installment!.planId; + const all = await TransactionModel.find({ "installment.planId": plan }) + .sort({ "installment.index": 1 }) + .lean(); + expect(all).toHaveLength(9); + expect(all.map((t) => t.installment!.index)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(all.reduce((a, t) => a + t.amountMinor, 0)).toBe(9_000_00); + const posted = all.filter((t) => t.status === "posted"); + expect(posted.length).toBeGreaterThanOrEqual(3); + expect(all.filter((t) => t.status === "planned").every((t) => t.date > new Date())).toBe(true); + expect(await balanceAt(card)).toBe( + balanceBefore - posted.reduce((a, t) => a + t.amountMinor, 0), + ); + + const s = (await getCardSummary(ayse, cakir, card))!; + const pending = all + .filter((t) => t.status === "planned") + .reduce((a, t) => a + t.amountMinor, 0); + expect(s.pendingInstallmentsMinor).toBe(summaryBefore.pendingInstallmentsMinor + pending); + expect(s.availableMinor).toBe(s.limitMinor - s.debtMinor - s.pendingInstallmentsMinor); + expect(s.upcomingInstallments.reduce((a, x) => a + x.totalMinor, 0)).toBe( + s.pendingInstallmentsMinor, + ); + + const list = await listTransactions( + ayse, + cakir, + (await import("@/shared/schemas/transaction")).transactionFilterSchema.parse({ + q: "Hepsiburada", + }), + ); + expect(list!.items[0]!.installment).toMatchObject({ count: 9 }); + }); + + it("vadesi gelen taksit kendi döneminde otomatik gerçekleşir", async () => { + const plan = (await TransactionModel.findById(first).lean())!.installment!.planId; + const plannedBefore = await TransactionModel.countDocuments({ + "installment.planId": plan, + status: "planned", + }); + await postDueInstallments( + (await AccountModel.findById(await acc("Garanti Bonus")).lean())!.householdId, + addDays(today, 40), + ); + const plannedAfter = await TransactionModel.countDocuments({ + "installment.planId": plan, + status: "planned", + }); + expect(plannedAfter).toBe(plannedBefore - 1); + }); + + it("kalan taksitler iptal edilebilir; gerçekleşenler korunur", async () => { + const plan = (await TransactionModel.findById(first).lean())!.installment!.planId; + const posted = await TransactionModel.countDocuments({ + "installment.planId": plan, + status: "posted", + }); + const r = await cancelRemainingInstallments(ayse, cakir, first); + expect(r).toMatchObject({ ok: true }); + expect( + await TransactionModel.countDocuments({ + "installment.planId": plan, + status: "planned", + deletedAt: null, + }), + ).toBe(0); + expect( + await TransactionModel.countDocuments({ + "installment.planId": plan, + status: "posted", + deletedAt: null, + }), + ).toBe(posted); + }); + + it("taksit yalnızca kredi kartı giderinde", async () => { + const onBank = await createTransaction( + ayse, + cakir, + await form({ installments: 3, accountId: await acc("Ziraat Maaş") }), + ); + expect(onBank.ok).toBe(false); + const base = { + type: "income", + amount: "100", + date: today, + accountId: "a".repeat(24), + categoryId: "b".repeat(24), + installments: "3", + }; + expect(transactionFormSchema.safeParse(base).success).toBe(false); + expect(transactionFormSchema.safeParse({ ...base, type: "expense" }).success).toBe(true); + expect( + transactionFormSchema.safeParse({ ...base, type: "expense", installments: "37" }).success, + ).toBe(false); + expect( + transactionFormSchema.parse({ ...base, type: "expense", installments: "" }).installments, + ).toBe(1); + }); +}); + +describe("KMH özeti", () => { + it("dönem içi eksi bakiyeden faiz tahmini; kullanılabilir KMH", async () => { + const created = await createAccount(ayse, cakir, { + name: "KMH Test", + type: "bank", + institution: "", + mealCardProvider: null, + openingBalance: 0, + openingDate: "2020-01-01", + color: "blue", + icon: "landmark", + ownerUserId: null, + isPrivate: false, + overdraftEnabled: true, + overdraftLimit: 10_000_00, + overdraftMonthlyRate: 425, + overdraftStatementDay: 1, + cardLimit: 0, + cardStatementDay: 1, + cardDueKind: "daysAfter", + cardDueValue: 10, + cardPaymentAccountId: null, + cardMinimumRate: null, + }); + expect(created.ok).toBe(true); + const id = created.ok ? created.data.id : ""; + const { statement } = cardPeriods(today, 1); + await createTransaction( + ayse, + cakir, + await form({ accountId: id, amount: 3_000_00, date: statement.start }), + ); + const s = (await getOverdraftSummary(ayse, cakir, id))!; + const days = + Math.round( + (istanbulDayStart(statement.end)!.getTime() - + istanbulDayStart(statement.start)!.getTime()) / + (24 * 3600 * 1000), + ) + 1; + expect(s.statement.interestMinor).toBe(Math.round((days * 3_000_00 * 425) / 10_000 / 30)); + expect(s.usedMinor).toBe(3_000_00); + expect(s.availableMinor).toBe(7_000_00); + // Artı bakiyeli hesapta kullanılabilir KMH limitin kendisi. + const z = (await getOverdraftSummary(ayse, cakir, await acc("Ziraat Maaş")))!; + expect(z.usedMinor).toBe(0); + expect(z.availableMinor).toBe(z.limitMinor); + expect(s.interestCategoryId).not.toBeNull(); + expect(s.interestBooked).toBe(false); + expect((await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === id)!.balanceMinor).toBe( + -3_000_00, + ); + expect(await getOverdraftSummary(ayse, cakir, await acc("Cüzdan"))).toBeNull(); + }); +}); diff --git a/src/features/transactions/card.ts b/src/features/transactions/card.ts new file mode 100644 index 0000000..672e1b3 --- /dev/null +++ b/src/features/transactions/card.ts @@ -0,0 +1,396 @@ +import "server-only"; +import { Types, type PipelineStage } from "mongoose"; +import { AccountModel } from "@/models/account"; +import { CardStatementModel } from "@/models/card-statement"; +import { CategoryModel } from "@/models/category"; +import { TransactionModel } from "@/models/transaction"; +import { + cardPeriods, + closingOnOrAfter, + dueDate, + estimateOverdraftInterest, + minimumPayment, + minimumRateBp, + nextPeriod, + previousPeriod, + type DueRule, + type Period, +} from "@/shared/card-periods"; +import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates"; +import { addDays } from "@/shared/recurrence"; +import { loadContext, toItem, userNames, type LeanTx, type TransactionListItem } from "./service"; +import { accountMatch, balanceBefore } from "./statement"; + +/** + * Kredi kartı özeti ve KMH dönem özeti (Bölüm 6.8). Tüm tutarlar işlemlerden hesaplanır; + * dönem kayıtları (`statements`) okunurken güncellenir. + */ + +const DAY_MS = 24 * 60 * 60 * 1000; +const HISTORY = 6; +const dayStart = (ymd: string) => istanbulDayStart(ymd)!; +const endExclusive = (ymd: string) => new Date(dayStart(ymd).getTime() + DAY_MS); + +type AccountDoc = { + _id: Types.ObjectId; + householdId: Types.ObjectId; + name: string; + type: string; + openingBalanceMinor: number; + archivedAt?: Date | null; + creditCard?: { + limitMinor: number; + statementDay: number; + dueRule: DueRule; + paymentAccountId?: Types.ObjectId | null; + minimumPaymentRateBp?: number | null; + } | null; + overdraft?: { + enabled: boolean; + limitMinor: number; + monthlyRateBp?: number | null; + statementDay?: number | null; + } | null; +}; + +/** Hesaba giriş (gelir, gelen transfer) ve çıkış (gider, giden transfer) toplamları. */ +async function flows(householdId: Types.ObjectId, accountId: Types.ObjectId, from: Date, to: Date) { + const pipeline: PipelineStage[] = [ + { $match: { ...accountMatch(householdId, accountId), date: { $gte: from, $lt: to } } }, + { + $group: { + _id: null, + inflow: { + $sum: { + $cond: [ + { + $or: [ + { $and: [{ $eq: ["$type", "income"] }, { $eq: ["$accountId", accountId] }] }, + { $and: [{ $eq: ["$type", "transfer"] }, { $eq: ["$toAccountId", accountId] }] }, + ], + }, + "$amountMinor", + 0, + ], + }, + }, + outflow: { + $sum: { + $cond: [ + { + $and: [ + { $in: ["$type", ["expense", "transfer"]] }, + { $eq: ["$accountId", accountId] }, + ], + }, + "$amountMinor", + 0, + ], + }, + }, + }, + }, + ]; + const [row] = await TransactionModel.aggregate<{ inflow: number; outflow: number }>(pipeline); + return { inflow: row?.inflow ?? 0, outflow: row?.outflow ?? 0 }; +} + +async function loadAccount(userId: string, householdId: string, accountId: string) { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return null; + const info = ctx.accountMap.get(accountId); + if (!info || info.hidden) return null; + const account = await AccountModel.findOne({ + _id: info.id, + householdId: ctx.m.householdId, + }).lean(); + return account ? { ctx, account } : null; +} + +// --- Kredi kartı --- + +export type StatementSummary = { + period: Period; + dueDate: string; + debtMinor: number; + minimumMinor: number; + paidMinor: number; + spentMinor: number; + remainingMinor: number; + minimumRemainingMinor: number; +}; + +export type CardSummary = { + limitMinor: number; + debtMinor: number; + availableMinor: number; + /** Henüz gerçekleşmemiş taksitlerin toplamı (limitten düşer). */ + pendingInstallmentsMinor: number; + minimumRateBp: number; + current: Period & { spentMinor: number }; + statement: StatementSummary; + daysToDue: number; + history: StatementSummary[]; + /** Dönem dönem yaklaşan taksit toplamları. */ + upcomingInstallments: { periodEnd: string; dueDate: string; totalMinor: number; count: number }[]; + /** Güncel ve sonraki dönemdeki planlı (taksit dışı) harcamalar. */ + planned: TransactionListItem[]; + paymentAccount: { id: string; name: string } | null; +}; + +export async function getCardSummary( + userId: string, + householdId: string, + accountId: string, + today = todayIstanbul(), +): Promise { + const loaded = await loadAccount(userId, householdId, accountId); + if (!loaded || loaded.account.type !== "credit_card" || !loaded.account.creditCard) return null; + const { ctx, account } = loaded; + const card = account.creditCard!; + const hh = ctx.m.householdId; + const S = card.statementDay; + const rate = minimumRateBp(card.limitMinor, card.minimumPaymentRateBp); + const { current, statement } = cardPeriods(today, S); + + const balanceNow = await balanceBefore(hh, account, null); + const debtMinor = Math.max(0, -balanceNow); + + // Son ekstre + geçmiş dönemler (yeniden eskiye). + const periods: Period[] = [statement]; + while (periods.length < HISTORY) periods.push(previousPeriod(periods.at(-1)!, S)); + const history = await Promise.all( + periods.map(async (p): Promise => { + const next = nextPeriod(p, S); + const [debtAt, paidFlows, spentFlows] = await Promise.all([ + balanceBefore(hh, account, endExclusive(p.end)), + flows(hh, account._id, endExclusive(p.end), endExclusive(next.end)), + flows(hh, account._id, dayStart(p.start), endExclusive(p.end)), + ]); + const debt = Math.max(0, -debtAt); + const minimum = minimumPayment(debt, rate); + return { + period: p, + dueDate: dueDate(p.end, card.dueRule), + debtMinor: debt, + minimumMinor: minimum, + paidMinor: paidFlows.inflow, + spentMinor: spentFlows.outflow, + remainingMinor: Math.max(0, debt - paidFlows.inflow), + minimumRemainingMinor: Math.max(0, minimum - paidFlows.inflow), + }; + }), + ); + const now = new Date(); + await CardStatementModel.bulkWrite( + history.map((h) => ({ + updateOne: { + filter: { accountId: account._id, periodEnd: h.period.end }, + update: { + $set: { + householdId: hh, + kind: "credit_card", + periodStart: h.period.start, + dueDate: h.dueDate, + statementDebtMinor: h.debtMinor, + minimumPaymentMinor: h.minimumMinor, + paidMinor: h.paidMinor, + spentMinor: h.spentMinor, + computedAt: now, + }, + }, + upsert: true, + }, + })), + ); + + const currentFlows = await flows( + hh, + account._id, + dayStart(current.start), + endExclusive(current.end), + ); + + // Planlı taksitler (dönemlerine göre) ve diğer planlı harcamalar. + const planned = (await TransactionModel.find({ + householdId: hh, + accountId: account._id, + status: "planned", + deletedAt: null, + }) + .sort({ date: 1 }) + .lean()) as unknown as LeanTx[]; + const byPeriod = new Map(); + let pendingInstallmentsMinor = 0; + const horizon = nextPeriod(current, S).end; + const other: LeanTx[] = []; + for (const tx of planned) { + if (tx.installment?.planId) { + const end = closingOnOrAfter(toIstanbulYmd(tx.date), S); + const x = byPeriod.get(end) ?? { totalMinor: 0, count: 0 }; + x.totalMinor += tx.amountMinor; + x.count++; + byPeriod.set(end, x); + pendingInstallmentsMinor += tx.amountMinor; + } else if (toIstanbulYmd(tx.date) <= horizon && tx.type !== "income") { + other.push(tx); + } + } + const names = await userNames(other.map((t) => t.createdBy as Types.ObjectId)); + const statementSummary = history[0]!; + const payer = card.paymentAccountId ? ctx.accountMap.get(String(card.paymentAccountId)) : null; + + return { + limitMinor: card.limitMinor, + debtMinor, + availableMinor: card.limitMinor - debtMinor - pendingInstallmentsMinor, + pendingInstallmentsMinor, + minimumRateBp: rate, + current: { ...current, spentMinor: currentFlows.outflow }, + statement: statementSummary, + daysToDue: Math.round( + (dayStart(statementSummary.dueDate).getTime() - dayStart(today).getTime()) / DAY_MS, + ), + history, + upcomingInstallments: [...byPeriod.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .slice(0, 12) + .map(([periodEnd, x]) => ({ periodEnd, dueDate: dueDate(periodEnd, card.dueRule), ...x })), + planned: other.slice(0, 10).map((tx) => toItem(ctx, tx, names)), + paymentAccount: + payer && !payer.hidden && !payer.archived ? { id: String(payer.id), name: payer.name } : null, + }; +} + +// --- KMH (esnek hesap) --- + +export type OverdraftSummary = { + limitMinor: number; + balanceMinor: number; + usedMinor: number; + availableMinor: number; + monthlyRateBp: number; + current: Period & { interestSoFarMinor: number }; + statement: Period & { interestMinor: number; averageUsedMinor: number }; + /** Faiz kaydı önerisi için "Banka › Faiz" kategorisi (varsa). */ + interestCategoryId: string | null; + /** Kesimden sonra bu hesapta faiz kategorisinde gider girilmiş mi? */ + interestBooked: boolean; +}; + +async function dailyDeltas( + householdId: Types.ObjectId, + accountId: Types.ObjectId, + period: Period, +): Promise> { + const rows = await TransactionModel.aggregate<{ _id: string; delta: number }>([ + { + $match: { + ...accountMatch(householdId, accountId), + date: { $gte: dayStart(period.start), $lt: endExclusive(period.end) }, + }, + }, + { + $group: { + _id: { $dateToString: { format: "%Y-%m-%d", date: "$date", timezone: "Europe/Istanbul" } }, + delta: { + $sum: { + $cond: [ + { $eq: ["$type", "transfer"] }, + { + $cond: [ + { $eq: ["$accountId", accountId] }, + { $multiply: ["$amountMinor", -1] }, + "$amountMinor", + ], + }, + { + $cond: [ + { $eq: ["$type", "income"] }, + "$amountMinor", + { $multiply: ["$amountMinor", -1] }, + ], + }, + ], + }, + }, + }, + }, + ]); + return new Map(rows.map((r) => [r._id, r.delta])); +} + +export async function getOverdraftSummary( + userId: string, + householdId: string, + accountId: string, + today = todayIstanbul(), +): Promise { + const loaded = await loadAccount(userId, householdId, accountId); + const od = loaded?.account.overdraft; + if (!loaded || loaded.account.type !== "bank" || !od?.enabled) return null; + const { ctx, account } = loaded; + const hh = ctx.m.householdId; + const S = od.statementDay ?? 1; + const rate = od.monthlyRateBp ?? 0; + const { current, statement } = cardPeriods(today, S); + + const [balanceMinor, stStart, curStart, stDeltas, curDeltas, faiz] = await Promise.all([ + balanceBefore(hh, account, null), + balanceBefore(hh, account, dayStart(statement.start)), + balanceBefore(hh, account, dayStart(current.start)), + dailyDeltas(hh, account._id, statement), + dailyDeltas(hh, account._id, current), + CategoryModel.findOne({ householdId: hh, defaultKey: "banka.faiz", archivedAt: null }) + .select({ _id: 1 }) + .lean(), + ]); + const st = estimateOverdraftInterest(statement, stStart, stDeltas, rate); + const cur = estimateOverdraftInterest(current, curStart, curDeltas, rate, today); + const interestBooked = faiz + ? Boolean( + await TransactionModel.exists({ + householdId: hh, + accountId: account._id, + type: "expense", + effectiveCategoryIds: faiz._id, + deletedAt: null, + status: { $ne: "void" }, + date: { $gte: dayStart(statement.end), $lt: endExclusive(addDays(today, 0)) }, + }), + ) + : false; + await CardStatementModel.updateOne( + { accountId: account._id, periodEnd: statement.end }, + { + $set: { + householdId: hh, + kind: "overdraft", + periodStart: statement.start, + statementDebtMinor: Math.max( + 0, + -(await balanceBefore(hh, account, endExclusive(statement.end))), + ), + interestMinor: st.interestMinor, + computedAt: new Date(), + }, + }, + { upsert: true }, + ); + return { + limitMinor: od.limitMinor, + balanceMinor, + usedMinor: Math.max(0, -balanceMinor), + // Kullanılabilir KMH: limit − kullanılan (pozitif bakiye KMH'yi artırmaz). + availableMinor: Math.max(0, od.limitMinor - Math.max(0, -balanceMinor)), + monthlyRateBp: rate, + current: { ...current, interestSoFarMinor: cur.interestMinor }, + statement: { + ...statement, + interestMinor: st.interestMinor, + averageUsedMinor: st.averageUsedMinor, + }, + interestCategoryId: faiz ? String(faiz._id) : null, + interestBooked, + }; +} diff --git a/src/features/transactions/service.int.test.ts b/src/features/transactions/service.int.test.ts index 18667f9..d273661 100644 --- a/src/features/transactions/service.int.test.ts +++ b/src/features/transactions/service.int.test.ts @@ -52,6 +52,7 @@ function form(over: Partial): TransactionForm { description: "", tags: [], attachmentIds: [], + installments: 1, ...over, }; } @@ -84,6 +85,7 @@ describe("bakiye tutarlılığı", () => { cardDueKind: "daysAfter" as const, cardDueValue: 10, cardPaymentAccountId: null, + cardMinimumRate: null, }; const b = await createAccount(ids.user("ayse"), cakir, { ...base, diff --git a/src/features/transactions/service.ts b/src/features/transactions/service.ts index 8f9a4fb..6d8db53 100644 --- a/src/features/transactions/service.ts +++ b/src/features/transactions/service.ts @@ -19,7 +19,8 @@ import { TransactionModel } from "@/models/transaction"; import type { AccountType } from "@/shared/accounts"; import { allowedAttachmentKinds } from "@/shared/attachments"; import type { IconKey, Swatch } from "@/shared/appearance"; -import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates"; +import { installmentSchedule } from "@/shared/card-periods"; +import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates"; import type { CategoryKind } from "@/shared/schemas/category"; import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction"; import { compareTr, foldTr } from "@/shared/search"; @@ -66,6 +67,7 @@ export async function loadContext( ) { const m = await resolveMembership(userId, householdId, minRole); if (!m) return null; + await postDueInstallments(m.householdId); const [accounts, categories] = await Promise.all([ AccountModel.find({ householdId: m.householdId }) .select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 }) @@ -145,9 +147,28 @@ export function baseFilter(ctx: Ctx): Record { * Hesap bazında işlem etkileri toplamı (kuruş): yalnızca posted ve silinmemiş işlemler. * transactionEffects ile aynı kural: gelir +, gider −, transfer kaynak − / hedef +. */ +/** + * Vadesi gelen taksitleri gerçekleştirir (planlı → posted). Okuma yollarında çağrılır; zamanlanmış + * görev gerekmez. Taksitler onay beklemez (Faz 7 kararı: kendi döneminde otomatik). + */ +export async function postDueInstallments(householdId: Types.ObjectId, today = todayIstanbul()) { + const tomorrow = istanbulDayStart(today)!.getTime() + DAY_MS; + await TransactionModel.updateMany( + { + householdId, + status: "planned", + deletedAt: null, + "installment.planId": { $type: "objectId" }, + date: { $lt: new Date(tomorrow) }, + }, + { $set: { status: "posted" } }, + ); +} + export async function computeAccountDeltas( householdId: Types.ObjectId, ): Promise> { + await postDueInstallments(householdId); const pipeline: PipelineStage[] = [ { $match: { householdId, status: "posted", deletedAt: null } }, { @@ -318,6 +339,11 @@ export async function createTransaction( const built = buildDoc(ctx, input); if (!built.ok) return built; const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null }; + if (input.installments > 1 && ctx.accountMap.get(String(d.accountId))?.type !== "credit_card") { + return fail(t.validation.installmentsCardOnly, { + installments: t.validation.installmentsCardOnly, + }); + } // Bölünmüş işlemde tek bir kategori önerilemez; satıcı kuralı yalnızca hesabı hatırlar. const payeeId = await touchPayee( ctx.m.householdId, @@ -330,21 +356,33 @@ export async function createTransaction( try { let id = ""; await mongoose.connection.transaction(async (session) => { - const [doc] = await TransactionModel.create( - [ - { - ...built.data, - householdId: ctx.m.householdId, - status: "posted", - payeeId, - createdBy: actor, - updatedBy: actor, - }, - ], - { session }, - ); - id = String(doc!._id); - await attach(ctx, actorId, doc!._id, input, session); + const base = { + ...built.data, + householdId: ctx.m.householdId, + status: "posted", + payeeId, + createdBy: actor, + updatedBy: actor, + }; + // Taksitli: her taksit ayrı işlem; vadesi gelmeyenler planlı (kendi döneminde gerçekleşir). + const today = todayIstanbul(); + const planId = new Types.ObjectId(); + const docs = + input.installments > 1 + ? installmentSchedule(input.date, input.amount, input.installments).map((x) => ({ + ...base, + amountMinor: x.amountMinor, + date: istanbulDayStart(x.date)!, + status: x.date <= today ? "posted" : "planned", + installment: { planId, index: x.index + 1, count: input.installments }, + })) + : [base]; + const created = await TransactionModel.insertMany(docs as Record[], { + session, + }); + const first = created[0]!; + id = String(first._id); + await attach(ctx, actorId, first._id, input, session); }); return ok({ id }); } catch (error) { @@ -486,6 +524,41 @@ export async function setTransactionDeleted( return ok(undefined); } +/** + * Kalan (henüz gerçekleşmemiş) taksitleri iptal eder — ör. ürün iadesi veya erken kapama. + * Gerçekleşmiş taksitlere dokunulmaz. + */ +export async function cancelRemainingInstallments( + actorId: string, + householdId: string, + transactionId: string, +): Promise> { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const tx = await loadOwnTransaction(ctx, transactionId); + const planId = tx?.installment?.planId; + if (!tx || !planId) return fail(tr.common.notFound); + const actor = new Types.ObjectId(actorId); + const res = await TransactionModel.updateMany( + { + householdId: ctx.m.householdId, + "installment.planId": planId, + status: "planned", + deletedAt: null, + }, + { $set: { deletedAt: new Date(), updatedBy: actor } }, + ); + await writeAudit({ + action: "transaction.delete", + actorId: actor, + householdId: ctx.m.householdId, + targetType: "installmentPlan", + targetId: String(planId), + diff: { cancelled: res.modifiedCount, count: tx.installment?.count ?? null }, + }); + return ok({ cancelled: res.modifiedCount }); +} + // --- Okuma --- export type TransactionListItem = { @@ -507,6 +580,8 @@ export type TransactionListItem = { isAdjustment: boolean; /** Mutabakatı yapılmış hesaplar. */ reconciledAccountIds: string[]; + /** Taksit: "4/9" */ + installment: { planId: string; index: number; count: number } | null; }; export type TransactionDetail = TransactionListItem & { @@ -567,6 +642,13 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map): Transa attachmentCount: tx.attachmentCount ?? 0, isAdjustment: Boolean(tx.isAdjustment), reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String), + installment: tx.installment?.planId + ? { + planId: String(tx.installment.planId), + index: tx.installment.index ?? 0, + count: tx.installment.count ?? 0, + } + : null, }; } diff --git a/src/features/transactions/statement.int.test.ts b/src/features/transactions/statement.int.test.ts index be6aa7a..1e51bde 100644 --- a/src/features/transactions/statement.int.test.ts +++ b/src/features/transactions/statement.int.test.ts @@ -100,6 +100,7 @@ describe("hesap ekstresi", () => { description: "", tags: [], attachmentIds: [], + installments: 1, } satisfies TransactionForm); expect(r.ok).toBe(true); const s = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!; @@ -172,6 +173,7 @@ describe("mutabakat", () => { description: "", tags: [], attachmentIds: [], + installments: 1, }); expect(edit.ok).toBe(false); expect((await setTransactionDeleted(ayse, cakir, adjustmentId, true)).ok).toBe(true); @@ -215,6 +217,7 @@ describe("mutabakat", () => { description: "yeni açıklama", tags: d.tags, attachmentIds: [], + installments: 1, }; expect((await updateTransaction(ayse, cakir, d.id, base)).ok).toBe(true); expect( diff --git a/src/features/transactions/statement.ts b/src/features/transactions/statement.ts index 1922505..c77f193 100644 --- a/src/features/transactions/statement.ts +++ b/src/features/transactions/statement.ts @@ -36,7 +36,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; const MAX_ROWS = 2000; /** Hesap bazında bakiyeyi etkileyen işlemler (posted, silinmemiş). */ -function accountMatch(householdId: Types.ObjectId, accountId: Types.ObjectId) { +export function accountMatch(householdId: Types.ObjectId, accountId: Types.ObjectId) { return { householdId, status: "posted" as const, @@ -46,7 +46,7 @@ function accountMatch(householdId: Types.ObjectId, accountId: Types.ObjectId) { } /** Açılış bakiyesi + (isteğe bağlı tarihten önceki) işlem etkileri. */ -async function balanceBefore( +export async function balanceBefore( householdId: Types.ObjectId, account: { _id: Types.ObjectId; openingBalanceMinor: number }, before: Date | null, diff --git a/src/models/account.ts b/src/models/account.ts index cd54770..00e15f6 100644 --- a/src/models/account.ts +++ b/src/models/account.ts @@ -26,6 +26,11 @@ const creditCardSchema = new Schema( kind: { type: String, enum: DUE_RULE_KINDS, default: "daysAfter" }, value: { type: Number, min: 1, max: 31, default: 10 }, }, + /** + * Asgari ödeme oranı, baz puan (2000 = %20). null → varsayılan kural: limit 50.000 TL altı %20, + * üstü %40 (Faz 7 kararı). + */ + minimumPaymentRateBp: { type: Number, min: 0, max: 10_000, default: null }, /** Kart borcunun ödeneceği varsayılan hesap. */ paymentAccountId: { type: Schema.Types.ObjectId, default: null }, }, diff --git a/src/models/card-statement.ts b/src/models/card-statement.ts new file mode 100644 index 0000000..c80f4ab --- /dev/null +++ b/src/models/card-statement.ts @@ -0,0 +1,38 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; + +/** + * Kart/KMH dönem kaydı (Bölüm 6.8, `statements`). Tutarlar işlemlerden hesaplanır ve kart özeti + * okunurken güncellenir (geriye dönük girilen işlem dönem borcunu doğru değiştirir); bu koleksiyon + * raporlar ve mobil API için son hesaplanan değerleri tutar. + */ +const cardStatementSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + accountId: { type: Schema.Types.ObjectId, required: true }, + kind: { type: String, enum: ["credit_card", "overdraft"], required: true }, + /** "YYYY-MM-DD" (İstanbul), ikisi de dahil. */ + periodStart: { type: String, required: true }, + periodEnd: { type: String, required: true }, + dueDate: { type: String, default: null }, + /** Kesim günü sonundaki borç (pozitif). */ + statementDebtMinor: { type: Number, default: 0 }, + minimumPaymentMinor: { type: Number, default: 0 }, + /** Kesimden sonra (bir sonraki kesime kadar) yapılan ödemeler. */ + paidMinor: { type: Number, default: 0 }, + /** Dönemdeki harcamalar (çıkışlar). */ + spentMinor: { type: Number, default: 0 }, + /** KMH: tahmini faiz. */ + interestMinor: { type: Number, default: null }, + computedAt: { type: Date, required: true }, + }, + { timestamps: true, collection: "statements" }, +); + +cardStatementSchema.index({ accountId: 1, periodEnd: 1 }, { unique: true }); +cardStatementSchema.index({ householdId: 1, accountId: 1, periodEnd: -1 }); + +export type CardStatement = InferSchemaType; + +export const CardStatementModel: Model = + (models.CardStatement as Model | undefined) ?? + model("CardStatement", cardStatementSchema); diff --git a/src/models/transaction.ts b/src/models/transaction.ts index e9bd92d..f0382ef 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -76,6 +76,7 @@ transactionSchema.index({ householdId: 1, accountId: 1, date: -1 }); transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 }); transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 }); transactionSchema.index({ householdId: 1, tags: 1 }); +transactionSchema.index({ "installment.planId": 1 }, { sparse: true }); transactionSchema.index( { recurringRuleId: 1, occurrenceIndex: 1 }, { unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } }, diff --git a/src/shared/card-periods.test.ts b/src/shared/card-periods.test.ts new file mode 100644 index 0000000..98a93a0 --- /dev/null +++ b/src/shared/card-periods.test.ts @@ -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); + }); +}); diff --git a/src/shared/card-periods.ts b/src/shared/card-periods.ts new file mode 100644 index 0000000..ca743e9 --- /dev/null +++ b/src/shared/card-periods.ts @@ -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, + 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 }; +} diff --git a/src/shared/schemas/account.ts b/src/shared/schemas/account.ts index 9dc52b3..e090f7a 100644 --- a/src/shared/schemas/account.ts +++ b/src/shared/schemas/account.ts @@ -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) { diff --git a/src/shared/schemas/transaction.ts b/src/shared/schemas/transaction.ts index 2a4012c..0fb9c48 100644 --- a/src/shared/schemas/transaction.ts +++ b/src/shared/schemas/transaction.ts @@ -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) {