feat(recurring): düzenli işlem kuralları, planlı kayıt üretimi, onay/atla/ertele ve seri düzenleme

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 10:55:05 +03:00
parent eaafe5dd9b
commit 04a17d2775
13 changed files with 1493 additions and 7 deletions
+607
View File
@@ -0,0 +1,607 @@
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: [],
};
}
/** 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<number> {
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<RuleDoc[]>();
let created = 0;
for (const rule of rules) created += await generate(rule, today);
return created;
}
function ruleVisible(ctx: Ctx, rule: Pick<RuleDoc, "accountId" | "toAccountId">) {
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<RuleDoc>();
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<ActionResult<{ id: string; generated: number }>> {
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<ActionResult<{ id: string }>> {
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<RuleDoc>();
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<ActionResult<{ removed: number }>> {
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<ActionResult> {
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,
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<ActionResult> {
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<ActionResult> {
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<string, unknown>, 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<PlannedOverview | null> {
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<RuleView[]> {
const rules = (
await RecurringRuleModel.find({ householdId: ctx.m.householdId, deletedAt: null })
.sort({ createdAt: 1 })
.lean<RuleDoc[]>()
).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<Awaited<ReturnType<typeof getRuleForm>>>;
/** Dashboard uyarısı: vadesi geçmiş onaylanmamış planlı kayıt sayısı. */
export async function countOverdue(userId: string, householdId: string): Promise<number> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return 0;
return TransactionModel.countDocuments({
...baseFilter(ctx),
status: "planned",
date: { $lt: istanbulDayStart(todayIstanbul())! },
});
}