import "server-only"; import mongoose, { Types, type ClientSession } from "mongoose"; import { baseFilter, buildDoc, loadContext, loadOwnTransaction, toItem, touchPayee, userNames, type Ctx, type LeanTx, type TransactionListItem, } from "@/features/transactions/service"; import { tr } from "@/i18n/tr"; import { fail, ok, type ActionResult } from "@/lib/action-result"; import { writeAudit } from "@/lib/audit"; import { toObjectId } from "@/lib/object-id"; import { RecurringRuleModel } from "@/models/recurring-rule"; import { TransactionModel } from "@/models/transaction"; import { addDays, describeRecurrence, horizonDate, listOccurrences, type Recurrence, } from "@/shared/recurrence"; import type { ConfirmOccurrenceForm, RecurringRuleForm } from "@/shared/schemas/recurring"; import type { TransactionForm } from "@/shared/schemas/transaction"; import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates"; import { foldTr } from "@/shared/search"; import type { TransactionType } from "@/shared/transactions"; /** * Planlı ve düzenli işlemler (Bölüm 6.7). Okuma: viewer; yazma: member. * Planlı kayıtlar bakiyeyi etkilemez; "Onayla" ile posted olur. Gizli hesap kuralları ve * kayıtları işlemlerle aynı kuralla yalnızca hesap sahibine görünür. */ const t = tr.recurring; /** "Yaklaşan" listesinin kapsadığı gün sayısı. */ export const UPCOMING_DAYS = 60; type RuleDoc = { _id: Types.ObjectId; householdId: Types.ObjectId; type: TransactionType; amountMinor: number; isEstimate: boolean; accountId: Types.ObjectId; toAccountId?: Types.ObjectId | null; categoryId?: Types.ObjectId | null; payeeName: string; description: string; tags: string[]; frequency: Recurrence["frequency"]; interval: number; monthlyMode: Recurrence["monthlyMode"]; startDate: string; endDate?: string | null; count?: number | null; generatedUntil?: string | null; createdBy: Types.ObjectId; updatedBy: Types.ObjectId; }; const toRecurrence = (r: RuleDoc): Recurrence => ({ frequency: r.frequency, interval: r.interval, monthlyMode: r.monthlyMode, startDate: r.startDate, endDate: r.endDate ?? null, count: r.count ?? null, }); function asTransactionForm(input: RecurringRuleForm): TransactionForm { return { type: input.type, amount: input.amount, date: input.startDate, accountId: input.accountId, toAccountId: input.toAccountId, categoryId: input.type === "transfer" ? null : input.categoryId, splits: [], payee: input.payee, description: input.description, tags: input.tags, attachmentIds: [], installments: 1, }; } /** Form → kural alanları (doğrulanmış işlem şablonu + tekrar). */ function ruleFields(ctx: Ctx, input: RecurringRuleForm, existing?: RuleDoc) { const built = buildDoc(ctx, asTransactionForm(input), existing); if (!built.ok) return built; const d = built.data as { type: TransactionType; accountId: Types.ObjectId; toAccountId: Types.ObjectId | null; categoryId: Types.ObjectId | null; amountMinor: number; payeeName: string; description: string; tags: string[]; }; return ok({ type: d.type, amountMinor: d.amountMinor, isEstimate: input.isEstimate, accountId: d.accountId, toAccountId: d.toAccountId, categoryId: d.categoryId, payeeName: d.payeeName, description: d.description, tags: d.tags, frequency: input.frequency, interval: input.interval, monthlyMode: input.monthlyMode, startDate: input.startDate, endDate: input.endDate, count: input.count, }); } /** Kuralın ufka kadar eksik planlı kayıtlarını üretir (mevcut sıra numaraları atlanır). */ async function generate(rule: RuleDoc, today: string, session?: ClientSession): Promise { const until = horizonDate(today); const occurrences = listOccurrences(toRecurrence(rule), until); const existing = new Set( ( await TransactionModel.find( { recurringRuleId: rule._id }, { occurrenceIndex: 1 }, { session }, ).lean() ).map((x) => x.occurrenceIndex), ); const missing = occurrences.filter((o) => !existing.has(o.index)); if (missing.length) { const docs = missing.map((o) => ({ householdId: rule.householdId, type: rule.type, status: "planned", date: istanbulDayStart(o.date)!, accountId: rule.accountId, toAccountId: rule.toAccountId ?? null, amountMinor: rule.amountMinor, categoryId: rule.categoryId ?? null, splits: [], effectiveCategoryIds: rule.categoryId ? [rule.categoryId] : [], payeeName: rule.payeeName, description: rule.description, searchText: foldTr(`${rule.payeeName} ${rule.description}`), tags: rule.tags, isEstimate: rule.isEstimate, recurringRuleId: rule._id, occurrenceIndex: o.index, createdBy: rule.createdBy, updatedBy: rule.updatedBy, })); try { await TransactionModel.insertMany(docs, { session, ordered: false }); } catch (error) { // Eşzamanlı üretim: benzersiz index kopyayı reddeder (transaction dışında). if (session || (error as { code?: number }).code !== 11000) throw error; } } await RecurringRuleModel.updateOne( { _id: rule._id }, { $set: { generatedUntil: until } }, { session }, ); return missing.length; } /** Hanedeki kuralların ufkunu ilerletir (okuma sırasında; zamanlanmış görev gerekmez). */ export async function ensureHorizon(householdId: Types.ObjectId, today = todayIstanbul()) { const until = horizonDate(today); const rules = await RecurringRuleModel.find({ householdId, deletedAt: null, $or: [{ generatedUntil: null }, { generatedUntil: { $lt: until } }], }).lean(); let created = 0; for (const rule of rules) created += await generate(rule, today); return created; } function ruleVisible(ctx: Ctx, rule: Pick) { return [rule.accountId, rule.toAccountId] .filter(Boolean) .every((id) => !ctx.accountMap.get(String(id))?.hidden); } async function loadRule(ctx: Ctx, ruleId: string, session?: ClientSession) { const id = toObjectId(ruleId); if (!id) return null; const rule = await RecurringRuleModel.findOne( { _id: id, householdId: ctx.m.householdId, deletedAt: null }, null, { session }, ).lean(); return rule && ruleVisible(ctx, rule) ? rule : null; } /** "Bu ve sonrakiler" için başlangıç tekrarı (kurala ait olmalı). */ async function occurrenceIndexOf(rule: RuleDoc, transactionId: string | undefined) { const id = transactionId ? toObjectId(transactionId) : null; if (!id) return null; const tx = await TransactionModel.findOne( { _id: id, recurringRuleId: rule._id, householdId: rule.householdId }, { occurrenceIndex: 1 }, ).lean(); return tx?.occurrenceIndex ?? null; } // --- Kural yazma --- export async function createRule( actorId: string, householdId: string, input: RecurringRuleForm, ): Promise> { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const fields = ruleFields(ctx, input); if (!fields.ok) return fields; const actor = new Types.ObjectId(actorId); let out = { id: "", generated: 0 }; await mongoose.connection.transaction(async (session) => { const [rule] = await RecurringRuleModel.create( [{ ...fields.data, householdId: ctx.m.householdId, createdBy: actor, updatedBy: actor }], { session }, ); const generated = await generate(rule!.toObject() as RuleDoc, todayIstanbul(), session); out = { id: String(rule!._id), generated }; }); return ok(out); } /** * Seri düzenleme. "all": kural güncellenir, seriden ayrılmamış planlı kayıtlar yeniden üretilir * (gerçekleşmiş/atlanmış kayıtlar korunur). "following": seri bu tekrardan bölünür — eski kural * önceki tekrarda biter, yeni kural bu tekrardan itibaren yeni değerlerle başlar. */ export async function updateRule( actorId: string, householdId: string, ruleId: string, input: RecurringRuleForm, scope: "all" | "following", fromTransactionId?: string, ): Promise> { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const rule = await loadRule(ctx, ruleId); if (!rule) return fail(tr.common.notFound); const fields = ruleFields(ctx, input, rule); if (!fields.ok) return fields; const actor = new Types.ObjectId(actorId); const from = scope === "following" ? await occurrenceIndexOf(rule, fromTransactionId) : 0; if (from === null) return fail(tr.common.notFound); const today = todayIstanbul(); let id = String(rule._id); await mongoose.connection.transaction(async (session) => { if (from === 0) { await RecurringRuleModel.updateOne( { _id: rule._id }, { $set: { ...fields.data, generatedUntil: null, updatedBy: actor } }, { session }, ); await TransactionModel.deleteMany( { recurringRuleId: rule._id, status: "planned", ...(scope === "all" ? { detached: false } : {}), }, { session }, ); const fresh = await RecurringRuleModel.findById(rule._id, null, { session }).lean(); await generate(fresh!, today, session); return; } // Böl: eski kural `from` tekrarından önce biter. await RecurringRuleModel.updateOne( { _id: rule._id }, { $set: { count: from, updatedBy: actor } }, { session }, ); await TransactionModel.deleteMany( { recurringRuleId: rule._id, status: "planned", occurrenceIndex: { $gte: from } }, { session }, ); const [next] = await RecurringRuleModel.create( [{ ...fields.data, householdId: ctx.m.householdId, createdBy: actor, updatedBy: actor }], { session }, ); id = String(next!._id); await generate(next!.toObject() as RuleDoc, today, session); }); return ok({ id }); } export async function deleteRule( actorId: string, householdId: string, ruleId: string, scope: "all" | "following", fromTransactionId?: string, ): Promise> { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const rule = await loadRule(ctx, ruleId); if (!rule) return fail(tr.common.notFound); const from = scope === "following" ? await occurrenceIndexOf(rule, fromTransactionId) : 0; if (from === null) return fail(tr.common.notFound); const actor = new Types.ObjectId(actorId); let removed = 0; await mongoose.connection.transaction(async (session) => { const res = await TransactionModel.deleteMany( { recurringRuleId: rule._id, status: "planned", occurrenceIndex: { $gte: from } }, { session }, ); removed = res.deletedCount; await RecurringRuleModel.updateOne( { _id: rule._id }, from === 0 ? { $set: { deletedAt: new Date(), updatedBy: actor } } : { $set: { count: from, updatedBy: actor } }, { session }, ); await writeAudit( { action: "recurring.delete", actorId: actor, householdId: ctx.m.householdId, targetType: "recurringRule", targetId: String(rule._id), diff: { scope, fromIndex: from, removedPlanned: removed }, }, session, ); }); return ok({ removed }); } // --- Planlı kayıt işlemleri --- async function loadPlanned(ctx: Ctx, transactionId: string) { const tx = await loadOwnTransaction(ctx, transactionId); return tx && tx.status === "planned" ? tx : null; } /** Onayla: planlı → gerçekleşti; tutar ve tarih düzeltilebilir. */ export async function confirmOccurrence( actorId: string, householdId: string, transactionId: string, input: ConfirmOccurrenceForm, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const tx = await loadPlanned(ctx, transactionId); if (!tx) return fail(tr.common.notFound); const date = istanbulDayStart(input.date); if (!date) return fail(tr.validation.date, { date: tr.validation.date }); if (tx.splits?.length && input.amount !== tx.amountMinor) return fail(t.errors.splitAmount); // Arşivlenmiş/kapalı hesap veya kategoriye bağlı kayıt onaylanamaz. for (const id of [tx.accountId, tx.toAccountId].filter(Boolean)) { const a = ctx.accountMap.get(String(id)); if (!a || a.archived) return fail(tr.transactions.validation.accountInvalid); } const payeeId = await touchPayee( ctx.m.householdId, tx.payeeName ?? "", tx.type, // Planlı kayıtlar cari hareketi değildir; kaynak hesap her zaman dolu. tx.accountId!, tx.categoryId ?? null, ); await TransactionModel.updateOne( { _id: tx._id, householdId: ctx.m.householdId, status: "planned" }, { $set: { status: "posted", amountMinor: input.amount, date, isEstimate: false, payeeId, updatedBy: new Types.ObjectId(actorId), }, }, ); return ok(undefined); } /** Atla: bu tekrar gerçekleşmeyecek (iptal); seri devam eder. */ export async function skipOccurrence( actorId: string, householdId: string, transactionId: string, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const tx = await loadPlanned(ctx, transactionId); if (!tx) return fail(tr.common.notFound); await TransactionModel.updateOne( { _id: tx._id, householdId: ctx.m.householdId, status: "planned" }, { $set: { status: "void", updatedBy: new Types.ObjectId(actorId) } }, ); return ok(undefined); } /** Ertele: tarihi ileri al (seri tarihleri değişmez; kayıt seriden ayrılır). */ export async function postponeOccurrence( actorId: string, householdId: string, transactionId: string, ymd: string, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const tx = await loadPlanned(ctx, transactionId); if (!tx) return fail(tr.common.notFound); const date = istanbulDayStart(ymd); if (!date || ymd <= toIstanbulYmd(tx.date)) return fail(t.errors.postponeDate, { date: t.errors.postponeDate }); await TransactionModel.updateOne( { _id: tx._id, householdId: ctx.m.householdId, status: "planned" }, { $set: { date, detached: true, updatedBy: new Types.ObjectId(actorId) } }, ); return ok(undefined); } // --- Okuma --- export type PlannedItem = TransactionListItem & { recurringRuleId: string | null; isEstimate: boolean; /** "3/12" (tekrar sayılı seride). */ seriesPosition: string | null; }; export type RuleView = { id: string; type: TransactionType; title: string; amountMinor: number; isEstimate: boolean; recurrence: string; account: string; toAccount: string | null; category: string | null; nextDate: string | null; /** Kalan planlı kayıt (tekrar sayılı seride kalan tekrar). */ remaining: number | null; startDate: string; endDate: string | null; count: number | null; }; export type PlannedOverview = { overdue: PlannedItem[]; upcoming: PlannedItem[]; rules: RuleView[]; }; async function plannedItems(ctx: Ctx, filter: Record, limit = 500) { const rows = (await TransactionModel.find({ ...baseFilter(ctx), status: "planned", ...filter }) .sort({ date: 1, createdAt: 1 }) .limit(limit) .lean()) as unknown as LeanTx[]; const ruleIds = [ ...new Set( rows .map((r) => r.recurringRuleId) .filter(Boolean) .map(String), ), ]; const rules = await RecurringRuleModel.find({ _id: { $in: ruleIds } }, { count: 1 }).lean(); const counts = new Map(rules.map((r) => [String(r._id), r.count ?? null])); const names = await userNames(rows.map((r) => r.createdBy as Types.ObjectId)); return rows.map((r): PlannedItem => { const ruleId = r.recurringRuleId ? String(r.recurringRuleId) : null; const count = ruleId ? counts.get(ruleId) : null; return { ...toItem(ctx, r, names), recurringRuleId: ruleId, isEstimate: Boolean(r.isEstimate), seriesPosition: count && r.occurrenceIndex !== null && r.occurrenceIndex !== undefined ? `${r.occurrenceIndex + 1}/${count}` : null, }; }); } export async function getPlannedOverview( userId: string, householdId: string, ): Promise { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; await ensureHorizon(ctx.m.householdId); const today = todayIstanbul(); const todayStart = istanbulDayStart(today)!; const end = istanbulDayStart(addDays(today, UPCOMING_DAYS + 1))!; const [overdue, upcoming, rules] = await Promise.all([ plannedItems(ctx, { date: { $lt: todayStart } }), plannedItems(ctx, { date: { $gte: todayStart, $lt: end } }), listRules(ctx), ]); return { overdue, upcoming, rules }; } async function listRules(ctx: Ctx): Promise { const rules = ( await RecurringRuleModel.find({ householdId: ctx.m.householdId, deletedAt: null }) .sort({ createdAt: 1 }) .lean() ).filter((r) => ruleVisible(ctx, r)); const next = await TransactionModel.aggregate<{ _id: Types.ObjectId; date: Date | null; planned: number; done: number; }>([ { $match: { recurringRuleId: { $in: rules.map((r) => r._id) }, deletedAt: null } }, { $group: { _id: "$recurringRuleId", date: { $min: { $cond: [{ $eq: ["$status", "planned"] }, "$date", null] } }, planned: { $sum: { $cond: [{ $eq: ["$status", "planned"] }, 1, 0] } }, done: { $sum: { $cond: [{ $eq: ["$status", "planned"] }, 0, 1] } }, }, }, ]); const nextMap = new Map(next.map((x) => [String(x._id), x])); const name = (id?: Types.ObjectId | null) => id ? (ctx.accountMap.get(String(id))?.name ?? "—") : null; return rules .map((r): RuleView => { const n = nextMap.get(String(r._id)); const category = r.categoryId ? ctx.categoryMap.get(String(r.categoryId)) : undefined; return { id: String(r._id), type: r.type, title: r.payeeName || r.description || category?.name || tr.transactions.types[r.type], amountMinor: r.amountMinor, isEstimate: r.isEstimate, recurrence: describeRecurrence(r), account: name(r.accountId)!, toAccount: name(r.toAccountId), category: category?.path ?? null, nextDate: n?.date ? toIstanbulYmd(n.date) : null, // Tekrar sayılı seride: toplam − gerçekleşen/atlanan; bitiş tarihlide: kalan planlı kayıt. remaining: r.count ? Math.max(0, r.count - (n?.done ?? 0)) : r.endDate ? (n?.planned ?? 0) : null, startDate: r.startDate, endDate: r.endDate ?? null, count: r.count ?? null, }; }) .filter((r) => r.nextDate !== null || r.remaining === null); } /** Düzenleme formu için kural değerleri. */ export async function getRuleForm(userId: string, householdId: string, ruleId: string) { const ctx = await loadContext(userId, householdId, "member"); if (!ctx) return null; const r = await loadRule(ctx, ruleId); if (!r) return null; return { id: String(r._id), type: r.type, amountMinor: r.amountMinor, isEstimate: r.isEstimate, accountId: String(r.accountId), toAccountId: r.toAccountId ? String(r.toAccountId) : null, categoryId: r.categoryId ? String(r.categoryId) : null, payee: r.payeeName, description: r.description, tags: r.tags, startDate: r.startDate, frequency: r.frequency, interval: r.interval, monthlyMode: r.monthlyMode, endDate: r.endDate ?? null, count: r.count ?? null, }; } export type RuleFormValues = NonNullable>>; /** Dashboard uyarısı: vadesi geçmiş onaylanmamış planlı kayıt sayısı. */ export async function countOverdue(userId: string, householdId: string): Promise { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return 0; return TransactionModel.countDocuments({ ...baseFilter(ctx), status: "planned", date: { $lt: istanbulDayStart(todayIstanbul())! }, }); }