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, }; }