feat(cards): kart/KMH dönem hesabı, taksitli harcama, kart ve KMH özeti servisleri, 006 migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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<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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user