From cc624af69fcd24e86af7b53113e9868f863a0535 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 11:19:17 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(cards):=20kart/KMH=20d=C3=B6nem=20hesa?= =?UTF-8?q?b=C4=B1,=20taksitli=20harcama,=20kart=20ve=20KMH=20=C3=B6zeti?= =?UTF-8?q?=20servisleri,=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) { From 0ad52757adc1c2e7149f81659f8feb743e403d27 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 11:19:17 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat(ui):=20kart=20=C3=B6zeti,=20KMH=20pane?= =?UTF-8?q?li,=20taksit=20se=C3=A7imi=20ve=20kalan=20taksit=20iptali?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/hesaplar/[id]/page.tsx | 11 + src/components/layout/nav-config.ts | 2 +- .../transactions/components/card-summary.tsx | 314 ++++++++++++++++++ .../components/statement-view.tsx | 13 + .../components/transaction-form.tsx | 76 ++++- .../components/transaction-parts.tsx | 43 ++- src/i18n/tr.ts | 62 ++++ 7 files changed, 512 insertions(+), 9 deletions(-) create mode 100644 src/features/transactions/components/card-summary.tsx diff --git a/src/app/(app)/hesaplar/[id]/page.tsx b/src/app/(app)/hesaplar/[id]/page.tsx index 8bd5cfa..83a25e7 100644 --- a/src/app/(app)/hesaplar/[id]/page.tsx +++ b/src/app/(app)/hesaplar/[id]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { hasHouseholdRole } from "@/features/households/roles"; import { StatementView } from "@/features/transactions/components/statement-view"; +import { getCardSummary, getOverdraftSummary } from "@/features/transactions/card"; import { currentMonth, getAccountStatement } from "@/features/transactions/statement"; import { tr } from "@/i18n/tr"; import { requireActiveHousehold } from "@/lib/dal"; @@ -21,10 +22,20 @@ export default async function AccountStatementPage({ const month = requested > now ? now : requested; const statement = await getAccountStatement(user.id, household.id, id, month); if (!statement) notFound(); + const [card, overdraft] = await Promise.all([ + statement.account.type === "credit_card" + ? getCardSummary(user.id, household.id, id) + : Promise.resolve(null), + statement.account.type === "bank" + ? getOverdraftSummary(user.id, household.id, id) + : Promise.resolve(null), + ]); return ( ); diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index c333d29..239e67f 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -27,7 +27,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 7.1; +export const CURRENT_PHASE = 7.2; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; diff --git a/src/features/transactions/components/card-summary.tsx b/src/features/transactions/components/card-summary.tsx new file mode 100644 index 0000000..b615a93 --- /dev/null +++ b/src/features/transactions/components/card-summary.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { ChevronDown, CreditCard, Landmark, Percent, Wallet } from "lucide-react"; +import { useState } from "react"; +import { useQuickAdd } from "@/components/layout/quick-add"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatDate, formatMoney } from "@/shared/format"; +import { formatMoneyInput } from "@/shared/money"; +import type { CardSummary, OverdraftSummary } from "../card"; +import { Amount, rowTitle } from "./transaction-parts"; + +const c = tr.card; +const o = tr.overdraft; +const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`); +const pct = (bp: number) => formatMoneyInput(bp).replace(/,00$/, ""); + +function Stat({ + label, + value, + tone, + sub, +}: { + label: string; + value: string; + tone?: string; + sub?: React.ReactNode; +}) { + return ( +
+

{label}

+

{value}

+ {sub ?

{sub}

: null} +
+ ); +} + +/** Limit kullanım çubuğu: borç + gelecek taksitler / limit. */ +function UsageBar({ used, pending, limit }: { used: number; pending: number; limit: number }) { + if (limit <= 0) return null; + const u = Math.min(100, (used / limit) * 100); + const p = Math.min(100 - u, (pending / limit) * 100); + return ( +
+
+
+
+ ); +} + +export function CardSummaryPanel({ + summary: s, + accountId, + canWrite, +}: { + summary: CardSummary; + accountId: string; + canWrite: boolean; +}) { + const { openTransactionForm } = useQuickAdd(); + const [historyOpen, setHistoryOpen] = useState(false); + const st = s.statement; + + function pay() { + openTransactionForm({ + kind: "create", + type: "transfer", + prefill: { + accountId: s.paymentAccount?.id, + toAccountId: accountId, + amountMinor: st.remainingMinor || undefined, + description: c.payDescription, + }, + }); + } + + return ( + + + + + {c.title} + + + +
+
+ + +
+ + {s.pendingInstallmentsMinor ? ( +

+ {c.pendingInstallments(formatMoney(s.pendingInstallmentsMinor))} +

+ ) : null} +
+ +
+
+

{c.statementPeriod}

+ + {c.periodRange(d(st.period.start), d(st.period.end))} + +
+
+ + + + +
+
+

+ {c.dueDate}: {d(st.dueDate)}{" "} + + {st.remainingMinor ? `· ${c.daysLeft(s.daysToDue)}` : `· ${c.paidInFull}`} + +

+ {canWrite ? ( + + ) : null} +
+
+ +
+
+

{c.currentPeriod}

+

+ {c.periodRange(d(s.current.start), d(s.current.end))} +

+
+ +
+ +
+

{c.upcomingInstallments}

+ {s.upcomingInstallments.length ? ( +
    + {s.upcomingInstallments.map((x) => ( +
  • + + {c.installmentPeriod(d(x.periodEnd), d(x.dueDate), x.count)} + + {formatMoney(x.totalMinor)} +
  • + ))} +
+ ) : ( +

{c.noInstallments}

+ )} +
+ + {s.planned.length ? ( +
+

{c.planned}

+
    + {s.planned.map((p) => ( +
  • + + {d(p.date)} · {rowTitle(p)} + + +
  • + ))} +
+
+ ) : null} + + + + + + +
+ + + + + + + + + + + + {s.history.map((h) => ( + + + + + + + + ))} + +
{c.historyHeaders.period}{c.historyHeaders.debt}{c.historyHeaders.minimum}{c.historyHeaders.paid}{c.historyHeaders.due}
+ {c.periodRange(d(h.period.start), d(h.period.end))} + {formatMoney(h.debtMinor)}{formatMoney(h.minimumMinor)}{formatMoney(h.paidMinor)}{d(h.dueDate)}
+
+
+
+
+
+ ); +} + +export function OverdraftPanel({ + summary: s, + accountId, + canWrite, +}: { + summary: OverdraftSummary; + accountId: string; + canWrite: boolean; +}) { + const { openTransactionForm } = useQuickAdd(); + const range = c.periodRange(d(s.statement.start), d(s.statement.end)); + + function bookInterest() { + openTransactionForm({ + kind: "create", + type: "expense", + prefill: { + accountId, + amountMinor: s.statement.interestMinor || undefined, + categoryId: s.interestCategoryId ?? undefined, + date: s.statement.end, + description: o.interestDescription(range), + }, + }); + } + + return ( + + + + + {o.title} + + + +
+ + + +
+ +
+
+

{o.lastPeriod}

+ {range} +
+
+ + {s.interestBooked ? ( +

{o.booked}

+ ) : canWrite && s.statement.interestMinor > 0 ? ( + + ) : null} +
+

{o.taxNote}

+
+

+ {o.currentSoFar}:{" "} + + {formatMoney(s.current.interestSoFarMinor)} + +

+
+
+ ); +} diff --git a/src/features/transactions/components/statement-view.tsx b/src/features/transactions/components/statement-view.tsx index 9627412..44f3ea1 100644 --- a/src/features/transactions/components/statement-view.tsx +++ b/src/features/transactions/components/statement-view.tsx @@ -27,6 +27,8 @@ import { todayIstanbul } from "@/shared/dates"; import { formatDate, formatMoney } from "@/shared/format"; import { previewReconciliationAction, reconcileAccountAction } from "../actions"; import type { AccountStatement, ReconcilePreview, StatementRow } from "../statement"; +import type { CardSummary, OverdraftSummary } from "../card"; +import { CardSummaryPanel, OverdraftPanel } from "./card-summary"; import { RowIcon, rowSubtitle, rowTitle, useTransactionDetail } from "./transaction-parts"; const t = tr.statement; @@ -57,9 +59,13 @@ function SignedMoney({ minor, className }: { minor: number; className?: string } export function StatementView({ statement: s, + card, + overdraft, canWrite, }: { statement: AccountStatement; + card?: CardSummary | null; + overdraft?: OverdraftSummary | null; canWrite: boolean; }) { const { openDetail, element: detailElement } = useTransactionDetail(canWrite); @@ -95,6 +101,13 @@ export function StatementView({ } /> + {card ? ( + + ) : null} + {overdraft ? ( + + ) : null} +
+ ) : null} + , + ) + : null} {detail.reconciledAccountIds.length ? row( tr.statement.reconcile.title, @@ -323,6 +356,14 @@ export function useTransactionDetail(canWrite: boolean) { openTransactionForm({ kind: "copy", transaction: d }); }} onDelete={setDeleting} + onCancelInstallments={(d) => { + close(); + startTransition(async () => { + const r = await cancelRemainingInstallmentsAction(d.id); + if (r.ok) toast.success(t.toast.installmentsCancelled(r.data.cancelled)); + else toast.error(r.error); + }); + }} /> !o && setDeleting(null)}> diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index b937c78..ed76a71 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -199,6 +199,9 @@ export const tr = { cardDueValueDay: "Ayın günü", cardPaymentAccount: "Ödeme hesabı", cardPaymentAccountNone: "— Seçilmedi —", + cardMinimumRate: "Asgari ödeme oranı (%)", + cardMinimumRateHint: + "Boş bırakılırsa limit 50.000 TL'nin altındaysa %20, üstündeyse %40 kullanılır.", }, summary: { assets: "Varlıklar", @@ -266,6 +269,11 @@ export const tr = { remainingOk: "Kalemler toplamı tutara eşit", fillRemaining: "Kalanı son kaleme ekle", suggested: "Bu satıcı için son kullanılan kategori ve hesap önerildi.", + installments: "Taksit", + singlePayment: "Peşin", + installmentCount: (n: number) => `${n} taksit`, + installmentHint: (n: number, each: string) => + `${n} × yaklaşık ${each}; vadesi gelen taksit kendi döneminde otomatik gerçekleşir.`, }, validation: { amountPositive: "Tutar sıfırdan büyük olmalı", @@ -277,6 +285,9 @@ export const tr = { splitSum: "Kalemlerin toplamı tutara eşit olmalı", accountInvalid: "Hesap bu hanede yok, arşivli veya size kapalı.", categoryInvalid: "Kategori bu hanede yok, arşivli veya işlem türüyle uyuşmuyor.", + installmentsMax: "En fazla 36 taksit", + installmentsExpenseOnly: "Taksit yalnızca bölünmemiş kredi kartı giderinde kullanılabilir.", + installmentsCardOnly: "Taksit yalnızca kredi kartı hesabında kullanılabilir.", }, list: { empty: "Bu filtrelerle işlem bulunamadı.", @@ -321,6 +332,7 @@ export const tr = { updated: "İşlem güncellendi.", deleted: "İşlem silindi.", restored: "İşlem geri alındı.", + installmentsCancelled: (n: number) => `${n} kalan taksit iptal edildi.`, }, deleteTitle: "İşlem silinsin mi?", deleteDescription: @@ -348,6 +360,7 @@ export const tr = { adjustmentReadOnly: "Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.", reconciled: "Mutabakatı yapıldı", + cancelInstallments: "Kalan taksitleri iptal et", readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.", noAccounts: "İşlem girmek için önce bir hesap gerekli.", noAccountsLink: "Hesaplara git", @@ -460,6 +473,55 @@ export const tr = { upload: "Dosya yüklenemedi.", }, }, + card: { + title: "Kart özeti", + debt: "Toplam borç", + available: "Kullanılabilir limit", + limitOf: (limit: string) => `Limit ${limit}`, + pendingInstallments: (amount: string) => `Gelecek taksitler limitten düşer: ${amount}`, + statementPeriod: "Hesap kesim dönemi", + currentPeriod: "Güncel dönem", + periodRange: (start: string, end: string) => `${start} – ${end}`, + statementDebt: "Dönem borcu", + minimum: (rate: string) => `Asgari ödeme (%${rate})`, + paid: "Ödenen", + remaining: "Kalan", + dueDate: "Son ödeme", + daysLeft: (n: number) => + n > 0 ? `${n} gün kaldı` : n === 0 ? "Bugün son gün" : `${-n} gün geçti`, + paidInFull: "Dönem borcu ödendi.", + spent: "Harcama", + pay: "Borcu öde", + payDescription: "Kart borcu ödemesi", + upcomingInstallments: "Yaklaşan taksitler", + installmentPeriod: (end: string, due: string, n: number) => + `${end} kesim · son ödeme ${due} · ${n} taksit`, + noInstallments: "Bekleyen taksit yok.", + planned: "Planlı harcamalar", + history: "Geçmiş ekstreler", + historyHeaders: { + period: "Dönem", + debt: "Borç", + minimum: "Asgari", + paid: "Ödenen", + due: "Son ödeme", + }, + }, + overdraft: { + title: "Esnek hesap (KMH)", + limit: "KMH limiti", + used: "Kullanılan", + available: "Kullanılabilir KMH", + rate: (rate: string) => `Aylık faiz %${rate}`, + lastPeriod: "Geçen dönem", + currentSoFar: "Bu dönem (şimdiye kadar)", + estimatedInterest: "Tahmini faiz", + averageUsed: (amount: string) => `Ortalama kullanım ${amount}`, + bookInterest: "Faiz kaydı ekle", + booked: "Bu dönemin faiz kaydı girilmiş.", + interestDescription: (range: string) => `KMH faizi (${range})`, + taxNote: "Tahmin vergiler (KKDF/BSMV) hariçtir; banka ekstresindeki tutarı girin.", + }, statement: { title: "Hesap ekstresi", back: "Hesaplar", From 6ae98a1cb48bb708b61f485352a9a49b8127beca Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 11:19:17 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(seed):=20=C3=A7ak=C4=B1=C5=9Fan=20trans?= =?UTF-8?q?fer=20seedRef'leri;=20=C3=B6rnek=20taksitli=20al=C4=B1=C5=9Fver?= =?UTF-8?q?i=C5=9Fler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/seed/installments.ts | 68 ++++++++++++++++++++++++++++++++++++ scripts/seed/run.int.test.ts | 16 +++++++++ scripts/seed/run.ts | 10 ++++++ scripts/seed/transactions.ts | 46 ++++++++++++++---------- 4 files changed, 121 insertions(+), 19 deletions(-) create mode 100644 scripts/seed/installments.ts diff --git a/scripts/seed/installments.ts b/scripts/seed/installments.ts new file mode 100644 index 0000000..4636441 --- /dev/null +++ b/scripts/seed/installments.ts @@ -0,0 +1,68 @@ +import type { Types } from "mongoose"; +import { createTransaction } from "@/features/transactions/service"; +import { AccountModel } from "@/models/account"; +import { CategoryModel } from "@/models/category"; +import { TransactionModel } from "@/models/transaction"; +import { todayIstanbul } from "@/shared/dates"; +import { addDays } from "@/shared/recurrence"; + +/** + * Örnek taksitli kart harcamaları (Faz 7b): bir kısmı gerçekleşmiş, kalanı planlı taksitler. + * İdempotent: aynı satıcıyla taksit planı varsa atlanır. + */ +const PURCHASES = [ + { + payee: "Hepsiburada", + description: "Çamaşır makinesi", + amountMinor: 18_000_00, + count: 9, + daysAgo: 75, + category: "ev", + by: "admin", + }, + { + payee: "Teknosa", + description: "Telefon", + amountMinor: 12_000_00, + count: 6, + daysAgo: 20, + category: "diger-gider", + by: "emre", + }, +]; + +export async function seedInstallments( + householdId: Types.ObjectId, + userIds: Map, +): Promise { + const today = todayIstanbul(); + const card = await AccountModel.findOne({ householdId, name: "Garanti Bonus" }).lean(); + if (!card) return 0; + let created = 0; + for (const p of PURCHASES) { + const exists = await TransactionModel.exists({ + householdId, + payeeName: p.payee, + "installment.planId": { $type: "objectId" }, + }); + if (exists) continue; + const category = await CategoryModel.findOne({ householdId, defaultKey: p.category }).lean(); + const r = await createTransaction(userIds.get(p.by)!, String(householdId), { + type: "expense", + amount: p.amountMinor, + date: addDays(today, -p.daysAgo), + accountId: String(card._id), + toAccountId: null, + categoryId: String(category!._id), + splits: [], + payee: p.payee, + description: p.description, + tags: [], + installments: p.count, + attachmentIds: [], + }); + if (!r.ok) throw new Error(`Seed taksiti oluşturulamadı (${p.payee}): ${r.error}`); + created++; + } + return created; +} diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index 26e97a3..c84b071 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -46,11 +46,27 @@ describe("seed", () => { accounts: 0, transactions: 0, recurringRules: 0, + installmentPlans: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); }); + it("aynı aydaki transferlerin hepsi oluşur (seedRef çakışması yok)", async () => { + const tx = getDb().collection("transactions"); + const months = new Set( + (await tx.find({ description: "ATM" }).toArray()).map((t) => t.seedRef.split(":")[1]), + ); + expect(months.size).toBeGreaterThanOrEqual(5); + // Her ay ATM'nin yanında kart ödemesi ve birikim de var (ayın 7'si geçmişse). + expect(await tx.countDocuments({ description: "Kart borcu ödemesi" })).toBeGreaterThanOrEqual( + months.size - 1, + ); + expect(await tx.countDocuments({ description: "Aylık birikim" })).toBeGreaterThanOrEqual( + months.size - 1, + ); + }); + it("roller ve askı durumu doğru", async () => { const users = await getDb() .collection("user") diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 348f7e2..d1ba35a 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -10,6 +10,7 @@ import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import { storedEmail } from "@/shared/account-email"; import { runMigrations } from "../migrations"; +import { seedInstallments } from "./installments"; import { seedRecurring } from "./recurring"; import { seedTransactions } from "./transactions"; import { @@ -45,6 +46,7 @@ export type SeedSummary = { accounts: number; transactions: number; recurringRules: number; + installmentPlans: number; }; accounts: SeedAccountRow[]; }; @@ -61,6 +63,7 @@ const DOMAIN_COLLECTIONS = [ "attachments", "reconciliations", "recurringRules", + "statements", ]; type AuthApi = ReturnType["api"]; @@ -111,6 +114,7 @@ export async function runSeed({ accounts: 0, transactions: 0, recurringRules: 0, + installmentPlans: 0, }, accounts: [], }; @@ -279,6 +283,12 @@ export async function runSeed({ summary.created.recurringRules = await seedRecurring(householdIds, userIdMap); if (summary.created.recurringRules) log(`✓ Örnek düzenli işlemler: ${summary.created.recurringRules}`); + summary.created.installmentPlans = await seedInstallments( + householdIds.get("Çakır Ailesi")!, + userIdMap, + ); + if (summary.created.installmentPlans) + log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`); // 7) Geliştirme hesapları tablosu const all = [ diff --git a/scripts/seed/transactions.ts b/scripts/seed/transactions.ts index 4b22ee7..c59932f 100644 --- a/scripts/seed/transactions.ts +++ b/scripts/seed/transactions.ts @@ -166,7 +166,8 @@ function cakirAiles(): SeedTx[] { by: "emre", }); - // Transferler: nakit çekme, kart ödemesi, birikim. + // Transferler: nakit çekme, kart ödemesi, birikim. Satıcı/kategori olmadığından seedRef + // türden üretilir; çakışmasın diye sıra numarası verilir (ATM 0 — mevcut veriyle uyumlu). add({ type: "transfer", ymd: d(2), @@ -176,24 +177,31 @@ function cakirAiles(): SeedTx[] { description: "ATM", by: "admin", }); - add({ - type: "transfer", - ymd: d(9), - amountMinor: tl(11_000, 15_000), - account: "Ziraat Maaş", - toAccount: "Garanti Bonus", - description: "Kart borcu ödemesi", - by: "admin", - }); - add({ - type: "transfer", - ymd: d(16), - amountMinor: 5_000_00, - account: "Ziraat Maaş", - toAccount: "Birikim (Vadeli)", - description: "Aylık birikim", - by: "ayse", - }); + add( + { + type: "transfer", + // Kesim 29, son ödeme ~8: ödeme son ödeme gününden önce. + ymd: d(7), + amountMinor: tl(11_000, 15_000), + account: "Ziraat Maaş", + toAccount: "Garanti Bonus", + description: "Kart borcu ödemesi", + by: "admin", + }, + 1, + ); + add( + { + type: "transfer", + ymd: d(16), + amountMinor: 5_000_00, + account: "Ziraat Maaş", + toAccount: "Birikim (Vadeli)", + description: "Aylık birikim", + by: "ayse", + }, + 2, + ); // Market: haftada 1–2; bir kısmı bölünmüş. const marketCount = faker.number.int({ min: 6, max: 8 }); From c711eb8d283d5329266a6c7634a0a297d7482369 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 11:19:17 +0300 Subject: [PATCH 4/4] chore(release): 0.7.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 +++++++++++++++++++++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e36430b..a1496e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,27 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] -Faz 7 (alt fazlar halinde; `v0.7.0` Faz 7b sonunda). +## [0.7.0] - 2026-11-05 + +Faz 7: planlı/düzenli işlemler, kredi kartı ve KMH dönemleri, taksitler (7a ve 7b alt fazları). + +### Eklendi — Faz 7b: kart ve KMH dönemleri, taksitler + +- Dönem hesabı (`shared/card-periods.ts`): kesim günü dahil "Hesap Kesim Dönemi", sonrası "Güncel Dönem"; kesim günü ayda yoksa ayın son günü; kesim günü bitmeden dönem kapanmış sayılmaz. Son ödeme: kesimden N gün sonra veya ayın sabit günü. +- Kart özeti (`/hesaplar/:id`, kredi kartı): toplam borç, kullanılabilir limit (gelecek taksitler dahil düşülür) ve kullanım çubuğu; hesap kesim dönemi borcu, asgari ödeme, ödenen, kalan, son ödeme tarihi ve kalan gün; güncel dönem harcaması; dönem dönem yaklaşan taksitler; planlı harcamalar; son 6 ekstre. +- Asgari ödeme: limit 50.000 TL altı %20, üstü %40; kart bazında oran girilebilir (hesap formu). +- "Borcu öde": ödeme hesabından karta transfer formu kalan dönem borcuyla dolu açılır; ödemeler dönem borcundan düşer (kısmi ödeme desteklenir). +- Taksitli harcama: kredi kartı giderinde taksit sayısı (2–36). Her taksit ayrı işlem ("Hepsiburada 4/9"); toplam kuruş kaybı olmadan bölünür; vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler planlıdır ve bakiyeyi etkilemez. Ayrıntıdan "Kalan taksitleri iptal et". +- KMH özeti (KMH'li banka hesabı): limit, kullanılan, kullanılabilir; geçen ve bu dönem için gün sonu eksi bakiyelerden tahmini faiz; "Faiz kaydı ekle" kesim tarihli, "Banka › Faiz" kategorili gider formunu doldurur (vergiler hariç not). +- `statements` koleksiyonu: kart/KMH dönem kayıtları (borç, asgari, ödenen, harcama, tahmini faiz); kart özeti okunurken işlemlerden yeniden hesaplanır. +- İşlem formuna ön doldurma desteği (kart ödemesi, faiz kaydı). +- Seed: iki taksitli kart alışverişi. +- `006` migration'ı: `statements` index'leri, taksit planı index'i. +- Testler: dönem/son ödeme/asgari/taksit planı/KMH faizi (birim); kart özeti tutarlılığı (bağımsız bakiye hesabıyla), ödeme etkisi, taksit üretimi ve otomatik gerçekleşme, taksit iptali, taksit kuralları, KMH faiz tahmini ve yetki (entegrasyon). + +### Düzeltildi — Faz 7b + +- Seed: aynı aydaki satıcısız/kategorisiz transferler aynı `seedRef`'i aldığı için kart borcu ödemesi ve aylık birikim transferleri hiç oluşmuyordu; artık oluşuyor (mevcut kayıtlar kopyalanmaz). Kart ödemesi son ödeme gününden önceye alındı. ### Eklendi — Faz 7a: planlı ve düzenli işlemler diff --git a/package-lock.json b/package-lock.json index 7d959e9..cce4e74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "butcem", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "butcem", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@hookform/resolvers": "^7.0.2", "@tanstack/react-table": "^9.3.1", diff --git a/package.json b/package.json index 419a6af..09694f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.6.0", + "version": "0.7.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": {