5d370a062e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
610 lines
20 KiB
TypeScript
610 lines
20 KiB
TypeScript
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())! },
|
||
});
|
||
}
|