cc624af69f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
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<AccountDoc>();
|
||
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<CardSummary | null> {
|
||
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<StatementSummary> => {
|
||
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<string, { totalMinor: number; count: number }>();
|
||
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<Map<string, number>> {
|
||
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<OverdraftSummary | null> {
|
||
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,
|
||
};
|
||
}
|