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:
2026-11-05 11:19:17 +03:00
parent 57067b0fd0
commit cc624af69f
22 changed files with 1113 additions and 19 deletions
+396
View File
@@ -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,
};
}