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:
@@ -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())! },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user