Files
butcem/src/features/recurring/service.ts
T

610 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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,
// 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<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())! },
});
}